diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index c5fc360..243b339 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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) - .filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col) - || schema[col] !== undefined) - .map(([col, val]) => [col, '==', typed(col, val)]) + const filters = [ + ...viewFilter, + ...Object.entries(sliceObj) + .filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col) + || schema[col] !== undefined) + // 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)