Return inserted rows from change operations for incremental grid updates

Instead of re-fetching all forecast data after scale/recode/clone/reference,
the routes now return the inserted rows directly. The frontend uses ag-Grid's
applyTransaction to add only the new rows, eliminating the full reload round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-01 12:04:28 -04:00
parent 08dc415bfd
commit cfee3e96b9
3 changed files with 26 additions and 22 deletions

View File

@ -84,7 +84,7 @@ ilog AS (
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
SELECT * FROM ins`.trim();
}
function buildScale() {
@ -124,7 +124,7 @@ ilog AS (
FROM base
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
SELECT * FROM ins`.trim();
}
function buildRecode() {
@ -146,16 +146,16 @@ ilog AS (
SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src
RETURNING id
RETURNING *
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src
RETURNING id
RETURNING *
)
SELECT (SELECT count(*) FROM neg) + (SELECT count(*) FROM ins) AS rows_affected`.trim();
SELECT * FROM neg UNION ALL SELECT * FROM ins`.trim();
}
function buildClone() {
@ -179,7 +179,7 @@ ilog AS (
{{exclude_clause}}
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
SELECT * FROM ins`.trim();
}
function buildUndo() {

View File

@ -451,6 +451,17 @@ function openForecast() {
/* ============================================================
FORECAST VIEW data loading
============================================================ */
function parseNumericRows(rows) {
const numericCols = state.colMeta
.filter(c => c.role === 'value' || c.role === 'units')
.map(c => c.cname);
return rows.map(row => {
const r = { ...row };
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
return r;
});
}
async function loadForecastData() {
if (!state.version) return;
document.getElementById('forecast-label').textContent =
@ -462,14 +473,7 @@ async function loadForecastData() {
}
showStatus('Loading forecast data...', 'info');
const rawData = await api('GET', `/versions/${state.version.id}/data`);
const numericCols = state.colMeta
.filter(c => c.role === 'value' || c.role === 'units')
.map(c => c.cname);
const data = rawData.map(row => {
const r = { ...row };
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
return r;
});
const data = parseNumericRows(rawData);
initPivotGrid(data);
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
} catch (err) {
@ -695,7 +699,7 @@ async function submitScale() {
document.getElementById('scale-value-incr').value = '';
document.getElementById('scale-units-incr').value = '';
document.getElementById('scale-note').value = '';
await loadForecastData();
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}
@ -721,7 +725,7 @@ async function submitRecode() {
});
showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success');
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; });
await loadForecastData();
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}
@ -750,7 +754,7 @@ async function submitClone() {
showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success');
document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; });
document.getElementById('clone-scale').value = '1';
await loadForecastData();
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}

View File

@ -99,7 +99,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json(result.rows[0]);
res.json({ rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -127,7 +127,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json(result.rows[0]);
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -183,7 +183,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json(result.rows[0]);
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -218,7 +218,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json(result.rows[0]);
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -255,7 +255,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json(result.rows[0]);
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });