diff --git a/CLAUDE.md b/CLAUDE.md index 94f14e9..593c638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -676,7 +676,7 @@ When the user clicks a pivot cell, `perspective-click` fires. The handler in `Fo Dragging across a block of cells selects a region. The datagrid runs in `edit_mode: SELECT_REGION` (forced on restore, so a saved layout can't switch it off) and reports the region as a `perspective-select` event carrying a Perspective **ViewWindow** — `{ start_row, end_row, start_col, end_col }`, *not* the per-row `insertConfigs` payload an older API used. It fires on every mouseover as the region grows, so the handler only records the latest window and a window-level `mouseup` commits it. A single-cell region is ignored there: `perspective-click` already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle. -Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. The grand-total row resolves to no dimension at all and is skipped; that would mean "the whole version". +Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. **A total cell is its displayed amount.** The grand-total row resolves to no row dimension, so its slice is whatever the column axis and the pivot's `==` filters leave — possibly `{}`, meaning *everything the pivot's filter shows*. An empty slice is accepted only alongside a non-empty `scope` (`normalizeSlices()` / `assertSelective()` in `routes/operations.js`); with no filter at all it would be the whole version, and is refused on both sides. `buildWhereAny()` reads a union containing `{}` as `TRUE`, the scope then ANDed on — dropping it would narrow the write to the other cells. **Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree. diff --git a/lib/sql_generator.js b/lib/sql_generator.js index b993705..e985ec7 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -727,8 +727,11 @@ function buildTerritoryClause(territory, territoryCol, alias = '') { // {Region:East, State:NY} and {Region:West, State:CA} would become // Region IN (East,West) AND State IN (NY,CA), which also matches East/CA. function buildWhereAny(slices, dimCols, versionId) { - const list = (slices || []).filter(s => s && Object.keys(s).length > 0); - if (list.length === 0) return 'TRUE'; + const list = (slices || []).filter(Boolean); + // An empty slice is a total cell, and the union with a total is the total. + // Dropping it instead would narrow the write to the other cells. The caller + // only lets one through under a scope, which is ANDed on afterwards. + if (list.length === 0 || list.some(s => Object.keys(s).length === 0)) return 'TRUE'; if (list.length === 1) return buildWhere(list[0], dimCols, versionId); const groups = list diff --git a/routes/operations.js b/routes/operations.js index 1c11dea..b25bfe9 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -42,11 +42,14 @@ module.exports = function(pool) { return (client || pool).query(sql); } - // accept either the legacy single `slice` object or the newer `slices` array, - // and drop any empty entries so an empty selection can never widen to TRUE + // accept either the legacy single `slice` object or the newer `slices` array. + // An empty slice is a total cell -- everything the pivot's filter shows -- so + // it is kept only when a scope came with it; without one it would be the + // whole version, and is dropped so an empty selection can never widen to TRUE function normalizeSlices(body) { const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice]; - return raw.filter(s => s && typeof s === 'object' && Object.keys(s).length > 0); + const scoped = Array.isArray(body.scope) && body.scope.length > 0; + return raw.filter(s => s && typeof s === 'object' && (scoped || Object.keys(s).length > 0)); } // Stamp the tag onto the log entry the operation just created. @@ -127,9 +130,13 @@ module.exports = function(pool) { // column. buildWhere silently drops unknown keys, so {"typo": "x"} would // otherwise reduce to TRUE and apply the operation to the whole version. // Refuse rather than let a malformed selection rewrite every row. - function assertSelective(slices, ctx) { + // An empty slice passes only under a scope (see normalizeSlices), which + // buildScopeClause refuses to let reduce to nothing. + function assertSelective(slices, ctx, scope) { const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]); + const scoped = Array.isArray(scope) && scope.length > 0; slices.forEach((sl, i) => { + if (scoped && Object.keys(sl).length === 0) return; const hits = Object.keys(sl).filter(k => allowed.has(k)); if (hits.length === 0) { const err = new Error( @@ -828,7 +835,7 @@ module.exports = function(pool) { try { const ctx = await getContext(parseInt(req.params.id), 'scale'); if (!guardOpen(ctx.version, res)) return; - assertSelective(slices, ctx); + assertSelective(slices, ctx, req.body.scope); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); @@ -939,7 +946,7 @@ module.exports = function(pool) { try { const ctx = await getContext(parseInt(req.params.id), 'recode'); if (!guardOpen(ctx.version, res)) return; - assertSelective(slices, ctx); + assertSelective(slices, ctx, req.body.scope); if (!assertMayRecodeTerritory(req, ctx, set, res)) return; const excludeClause = buildExcludeClause(ctx.version.exclude_iters); @@ -1033,7 +1040,7 @@ module.exports = function(pool) { try { const ctx = await getContext(parseInt(req.params.id), 'clone'); if (!guardOpen(ctx.version, res)) return; - assertSelective(slices, ctx); + assertSelective(slices, ctx, req.body.scope); const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; const dateOffset = (date_offset || '0 days').trim() || '0 days'; diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 8353e15..77d4c98 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -39,7 +39,7 @@ function dispVal(v) { function sliceLabel(s) { const entries = Object.entries(s) - return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : '—' + return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : 'Total' } // A light grouping with an optional caption — a rule between blocks in the @@ -275,6 +275,10 @@ function SelectionList({ slices, viewScope = [], distinctSlices, currentTotals, ? {sliceLabel(s)} : (