Make the pivot's filter scope the ledger and the write

perspective-click reports only the clicked cell's own dimensions -- the
view-level filter is not in it -- so a slice never carried the season the
grid was scoped to. The ledger therefore counted rows the grid was hiding
(921,225.71 on screen against 956,485.13 in the panel) and an operation
would have written them.

Both now read the filter off the viewer. The ledger applies it to its own
view, where the values are already in the table's types and any operator
works. The operation merges the equalities into each slice, cell values
winning on a shared column since a cell cannot contradict the filter it was
drawn inside, and refuses outright on any other operator: a range or an
in-list cannot travel in a slice, and dropping it silently is the widening
this is meant to stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 08:39:44 -04:00
parent 6b63e9a5f3
commit 9753846d34

View File

@ -430,6 +430,22 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// filter to nothing, it is dropped: the ledger then totalled rows the pivot
// was hiding, which is how a season filter on sseas_e went unnoticed while
// the numbers disagreed by exactly the out-of-season rows.
// The pivot's own filter is not part of a clicked slice -- perspective-click
// reports only the cell's own dimensions -- so the ledger has to read it off
// the viewer and apply it alongside. Without this the ledger totals rows the
// grid is hiding, and the operation writes them: a grid scoped to
// sseas_e = 2027 gave a cell of 921,225.71 against a ledger of 956,485.13.
//
// Taken from viewer.save(), so the values are already in the table's own
// types and the operators are whatever the user set -- ranges and in-lists
// included, which a slice cannot express.
const viewFilter = await (async () => {
try {
const cfg = await viewerRef.current?.save()
return (cfg?.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
} catch { return [] }
})()
const schema = await tableRef.current.schema()
const typed = (col, val) => {
switch (schema[col]) {
@ -445,10 +461,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// columns in the loaded table, so here they filter directly. They have to
// be applied, or the ledger totals a wider selection than the operation
// will write.
const filters = Object.entries(sliceObj)
const filters = [
...viewFilter,
...Object.entries(sliceObj)
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|| schema[col] !== undefined)
.map(([col, val]) => [col, '==', typed(col, val)])
// a cell inside the filtered view cannot contradict it, so a repeated
// column is the same predicate twice and harmless
.filter(([col]) => !viewFilter.some(f => f[0] === col))
.map(([col, val]) => [col, '==', typed(col, val)]),
]
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
@ -1259,7 +1281,24 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
async function submitOp(op) {
if (!slices.length) { flash('Select a slice first', 'error'); return }
const body = buildPayload(op)
// The pivot's filter scopes what the ledger counted, so it has to scope what
// gets written too -- otherwise the panel shows one number and the operation
// changes a larger set. Only equalities can travel in a slice; anything else
// is refused rather than dropped, because dropping it is exactly the silent
// widening this is here to prevent.
let viewFilter = []
try {
const cfg = await viewerRef.current?.save()
viewFilter = (cfg?.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
} catch { viewFilter = [] }
const unsendable = viewFilter.filter(f => f[1] !== '==')
if (unsendable.length) {
flash(`The pivot filter ${unsendable.map(f => f.join(' ')).join(', ')} cannot be `
+ `applied to an operation. Narrow the selection instead, or use "==".`, 'error')
return
}
const body = buildPayload(op, viewFilter)
if (!body) return
if (body.slices.some(sl => !Object.keys(sl).length)) {
flash('No dimension or date columns in slice — check col_meta', 'error'); return
@ -1388,8 +1427,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
return out
}
function buildPayload(op) {
function buildPayload(op, viewFilter = []) {
if (!slices.length) return null
const scope = Object.fromEntries(viewFilter.map(([col, , val]) => [col, val]))
// Two clicked cells can differ only by a column the operation cannot filter on
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become
// the same effective slice, and sending it twice would apply the change twice
@ -1397,7 +1437,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const seen = new Set()
const effectiveSlices = []
for (const sl of slices) {
const eff = buildEffectiveSlice(sl)
// the view's scope first, so a cell's own value wins if they name the
// same column -- it cannot contradict the filter it was drawn inside
const eff = buildEffectiveSlice({ ...scope, ...sl })
const key = JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]]))
if (seen.has(key)) continue
seen.add(key)