diff --git a/lib/sql_generator.js b/lib/sql_generator.js
index fdba4d1..c9942e8 100644
--- a/lib/sql_generator.js
+++ b/lib/sql_generator.js
@@ -251,6 +251,35 @@ function buildWhere(slice, dimCols) {
return parts.length ? parts.join('\nAND ') : 'TRUE';
}
+// build a WHERE clause spanning several slices — an OR of AND-groups.
+// A union of slices cannot be flattened into one IN list per column: slices
+// {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) {
+ const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
+ if (list.length === 0) return 'TRUE';
+ if (list.length === 1) return buildWhere(list[0], dimCols);
+
+ const groups = list
+ .map(s => buildWhere(s, dimCols))
+ .filter(w => w !== 'TRUE');
+
+ // any slice that reduced to TRUE selects everything, so the union does too
+ if (groups.length !== list.length) return 'TRUE';
+
+ // outer parens matter: the caller appends `AND pf_iter NOT IN (...)`,
+ // and AND binds tighter than OR
+ return `(${groups.map(g => `(${g.replace(/\n/g, ' ')})`).join('\n OR ')})`;
+}
+
+// the bare predicate for "this row participates in operations", for use in a
+// FILTER clause where the excluded rows still need to be counted separately
+function buildExcludePredicate(excludeIters) {
+ if (!excludeIters || excludeIters.length === 0) return 'TRUE';
+ const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
+ return `pf_iter NOT IN (${list})`;
+}
+
// build AND iter NOT IN (...) from a version's exclude_iters array
function buildExcludeClause(excludeIters) {
if (!excludeIters || excludeIters.length === 0) return '';
@@ -309,4 +338,4 @@ function esc(val) {
return String(val).replace(/'/g, "''");
}
-module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc };
+module.exports = { generateSQL, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
diff --git a/routes/log.js b/routes/log.js
index d14d0f1..93bd0d3 100644
--- a/routes/log.js
+++ b/routes/log.js
@@ -85,13 +85,28 @@ module.exports = function(pool) {
}
});
- // update the note on a log entry
+ // update the note and/or tag on a log entry. Both are annotations — they never
+ // affect the forecast rows — so they stay editable after the fact, including on
+ // a closed version, where relabelling history is still legitimate.
router.patch('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid);
- const { note } = req.body;
+ const { note, tag } = req.body;
+ if (note === undefined && tag === undefined) {
+ return res.status(400).json({ error: 'Nothing to update — send note and/or tag' });
+ }
try {
+ // COALESCE on the flag, not the value: an explicit null or '' must be
+ // able to clear a field, which COALESCE on the value alone would ignore
const result = await pool.query(
- `UPDATE pf.log SET note = $1 WHERE id = $2 RETURNING *`, [note ?? null, logId]
+ `UPDATE pf.log SET
+ note = CASE WHEN $2::bool THEN $3::text ELSE note END,
+ tag = CASE WHEN $4::bool THEN $5::text ELSE tag END
+ WHERE id = $1 RETURNING *`,
+ [
+ logId,
+ note !== undefined, note === undefined ? null : (String(note).trim() || null),
+ tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
+ ]
);
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
res.json(result.rows[0]);
diff --git a/routes/operations.js b/routes/operations.js
index b7da282..d397dce 100644
--- a/routes/operations.js
+++ b/routes/operations.js
@@ -1,14 +1,189 @@
const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow');
-const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
+const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
- async function runSQL(sql) {
+ async function runSQL(sql, client) {
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
- return pool.query(sql);
+ 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
+ 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);
+ }
+
+ // Stamp the tag onto the log entry the operation just created.
+ // Done as a follow-up UPDATE rather than inside the generated SQL: those
+ // templates live in pf.sql per source, so adding a {{tag}} token would strand
+ // every source that has not re-run "Generate SQL".
+ async function tagLog(client, rows, tag) {
+ const clean = (tag || '').trim();
+ if (!clean) return null;
+ const ids = [...new Set(rows.map(r => r.pf_logid).filter(id => id != null))];
+ if (ids.length === 0) return null;
+ await client.query(`UPDATE pf.log SET tag = $1 WHERE id = ANY($2::bigint[])`, [clean, ids]);
+ return clean;
+ }
+
+ // A slice is only meaningful if at least one of its keys is a filterable
+ // 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) {
+ const allowed = new Set(ctx.filterCols);
+ slices.forEach((sl, i) => {
+ const hits = Object.keys(sl).filter(k => allowed.has(k));
+ if (hits.length === 0) {
+ const err = new Error(
+ `Slice ${i + 1} does not name any filterable column ` +
+ `(${JSON.stringify(sl)}). Expected one of: ${ctx.filterCols.join(', ')}.`
+ );
+ err.status = 400;
+ throw err;
+ }
+ });
+ }
+
+ // echo back what the caller asked for, for the audit log
+ function pickIntent(body) {
+ const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
+ 'target_value', 'target_units', 'target_price'];
+ const out = {};
+ for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
+ return out;
+ }
+
+ // Totals for a WHERE clause, split into the rows operations can change and the
+ // rows they cannot. Excluded iters (typically 'reference') are still visible in
+ // the pivot, so their contribution has to be reported rather than dropped —
+ // otherwise a target set against what the grid shows lands somewhere else.
+ async function sliceTotals(client, ctx, whereClause, excludeClause) {
+ const pred = buildExcludePredicate(ctx.version.exclude_iters);
+ const agg = (col, filter) => col ? `sum(${col}) FILTER (WHERE ${filter})` : 'NULL';
+ const v = ctx.valueCol ? `"${ctx.valueCol}"` : null;
+ const u = ctx.unitsCol ? `"${ctx.unitsCol}"` : null;
+
+ const r = await client.query(`
+ SELECT ${agg(v, pred)} AS total_value,
+ ${agg(u, pred)} AS total_units,
+ ${agg(v ? `abs(${v})` : null, pred)} AS abs_value,
+ ${agg(u ? `abs(${u})` : null, pred)} AS abs_units,
+ ${agg(v, `NOT (${pred})`)} AS excl_value,
+ ${agg(u, `NOT (${pred})`)} AS excl_units
+ FROM ${ctx.table} WHERE ${whereClause}
+ `);
+ const n = (x) => parseFloat(r.rows[0][x]) || 0;
+ return {
+ value: n('total_value'), units: n('total_units'),
+ absValue: n('abs_value'), absUnits: n('abs_units'),
+ exclValue: n('excl_value'), exclUnits: n('excl_units'),
+ };
+ }
+
+ // Resolve each measure independently into the increment the scale SQL expects.
+ // Value and units each accept exactly one of: an absolute target, a change
+ // amount, or a percentage — whichever the caller sent. They are resolved
+ // separately so a target on one measure and a percentage on the other can be
+ // submitted together. Everything is measured against the totals of *this*
+ // WHERE clause, which is what makes apply_mode 'each' land per slice.
+ async function resolveIncrs(client, ctx, whereClause, excludeClause, body) {
+ const num = (v) => (v === undefined || v === null || v === '') ? null : parseFloat(v);
+
+ const tValue = num(body.target_value);
+ const tUnits = num(body.target_units);
+ const tPrice = num(body.target_price);
+ const vIncr = num(body.value_incr);
+ const uIncr = num(body.units_incr);
+ let vPct = num(body.value_pct);
+ let uPct = num(body.units_pct);
+
+ // legacy shape: a single `pct` flag meaning "the increments are percentages"
+ if (body.pct) {
+ if (vPct === null && vIncr !== null) vPct = vIncr;
+ if (uPct === null && uIncr !== null) uPct = uIncr;
+ }
+ const legacyPct = !!body.pct;
+
+ const anyInput = [tValue, tUnits, tPrice, vIncr, uIncr, vPct, uPct].some(v => v !== null);
+ if (!anyInput) return { value: 0, units: 0 };
+
+ const totals = await sliceTotals(client, ctx, whereClause, excludeClause);
+
+ // What the number is measured against:
+ // 'adjustable' — only the rows this operation can write (the default, and
+ // what every earlier version of this API did)
+ // 'selected' — everything the pivot shows for the slice, excluded rows
+ // included. Those rows cannot move, so reaching the target
+ // means the adjustable rows absorb the whole difference.
+ const basis = body.target_basis === 'selected' ? 'selected' : 'adjustable';
+ const fixedValue = basis === 'selected' ? totals.exclValue : 0;
+ const fixedUnits = basis === 'selected' ? totals.exclUnits : 0;
+
+ // one measure: target wins, then percentage, then a plain change amount
+ const resolve = (target, pct, incr, current, fixed) => {
+ // subtract the immovable part: current + incr + fixed === target
+ if (target !== null) return (target - fixed) - current;
+ // a percentage of the basis, which may include the immovable part
+ if (pct !== null) return (current + fixed) * pct / 100;
+ if (incr !== null && !legacyPct) return incr;
+ return 0;
+ };
+
+ let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
+ const units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
+
+ // a price target holds units constant: new value = price x current units.
+ // An explicit value target outranks it.
+ if (tPrice !== null && tValue === null) {
+ value = (tPrice * (totals.units + fixedUnits)) - (totals.value + fixedValue);
+ }
+
+ // the scale SQL divides by the slice total; with no rows there is
+ // nothing to prorate across and the increment would vanish anyway
+ if (totals.value === 0 && totals.units === 0) return { value: 0, units: 0 };
+
+ // Refuse to prorate across a pool that nets to ~zero. Each row's new value is
+ // (row / total) * increment, so as the net approaches zero the multiplier
+ // explodes and rows fly apart in opposite directions to hit the target — a
+ // mathematically faithful, practically useless result. Selecting slices that
+ // offset each other is the usual cause, and 'each' handles that correctly.
+ assertProratable(totals, value, units);
+
+ return { value: round(value, 6), units: round(units, 6) };
+ }
+
+ // a pool is proratable only if its net is a meaningful fraction of its gross
+ const NET_TO_GROSS_FLOOR = 0.01;
+
+ function assertProratable(totals, value, units) {
+ const check = (net, gross, incr, label) => {
+ if (!incr) return;
+ if (gross === 0) return;
+ if (Math.abs(net) >= gross * NET_TO_GROSS_FLOOR) return;
+ const err = new Error(
+ `Cannot prorate ${label} across this selection: the rows net to ` +
+ `${net.toFixed(2)} against a gross of ${gross.toFixed(2)}, so they very ` +
+ `nearly cancel out. Scaling to a target would push them to extreme ` +
+ `opposite values. Use "Each" to scale every slice on its own, or narrow ` +
+ `the selection so it does not mix offsetting rows.`
+ );
+ err.status = 400;
+ throw err;
+ };
+ check(totals.value, totals.absValue, value, 'value');
+ check(totals.units, totals.absUnits, units, 'units');
+ }
+
+ function round(n, dp) {
+ if (!isFinite(n)) return 0;
+ const f = Math.pow(10, dp);
+ return Math.round(n * f) / f;
}
// fetch everything needed to execute an operation:
@@ -299,131 +474,196 @@ module.exports = function(pool) {
}
});
- // scale a slice — adjust value and/or units by absolute amount or percentage
+ // scale one or more slices — adjust value and/or units toward an absolute
+ // target or by an increment. With several slices selected, apply_mode decides
+ // whether they are treated as one pool ('prorate') or independently ('each').
router.post('/versions/:id/scale', async (req, res) => {
- const { pf_user, note, slice, value_incr, units_incr, pct } = req.body;
- if (!slice || Object.keys(slice).length === 0) {
- return res.status(400).json({ error: 'slice is required' });
- }
+ const { pf_user, note, apply_mode } = req.body;
+ const slices = normalizeSlices(req.body);
+ if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
+
+ const applyMode = apply_mode === 'each' ? 'each' : 'prorate';
+
try {
const ctx = await getContext(parseInt(req.params.id), 'scale');
if (!guardOpen(ctx.version, res)) return;
+ assertSelective(slices, ctx);
- const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
- let absValueIncr = value_incr || 0;
- let absUnitsIncr = units_incr || 0;
+ // 'prorate' pools every slice into one WHERE and lets the SQL's
+ // sum() OVER () distribute the increment across the whole pool.
+ // 'each' runs the same statement once per slice, so every slice
+ // reaches the target on its own and gets its own log entry.
+ const units = applyMode === 'each'
+ ? slices.map(sl => ({ slices: [sl], where: buildWhere(sl, ctx.filterCols) }))
+ : [{ slices, where: buildWhereAny(slices, ctx.filterCols) }];
- // pct mode: run a quick totals query, convert percentages to absolutes
- if (pct && (value_incr || units_incr)) {
- const totals = await pool.query(`
- SELECT
- sum("${ctx.valueCol}") AS total_value,
- sum("${ctx.unitsCol}") AS total_units
- FROM ${ctx.table}
- WHERE ${whereClause}
- ${excludeClause}
- `);
- const { total_value, total_units } = totals.rows[0];
- if (value_incr) absValueIncr = (parseFloat(total_value) || 0) * value_incr / 100;
- if (units_incr) absUnitsIncr = (parseFloat(total_units) || 0) * units_incr / 100;
+ const client = await pool.connect();
+ let committed = false;
+ try {
+ await client.query('BEGIN');
+ const allRows = [];
+ let applied = 0;
+ const skipped = [];
+
+ for (const unit of units) {
+ const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body);
+ // no rows, or already at the target — nothing to write for this slice
+ if (incr.value === 0 && incr.units === 0) { skipped.push(...unit.slices); continue; }
+ applied++;
+
+ const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
+ const sql = applyTokens(ctx.sql, {
+ fc_table: ctx.table,
+ version_id: ctx.version.id,
+ pf_user: esc(pf_user || ''),
+ note: esc(note || ''),
+ params: esc(JSON.stringify({
+ slices: unit.slices,
+ apply_mode: applyMode,
+ ...pickIntent(req.body),
+ resolved: { value_incr: incr.value, units_incr: incr.units }
+ })),
+ slice: esc(JSON.stringify(loggedSlice)),
+ where_clause: unit.where,
+ exclude_clause: excludeClause,
+ value_incr: incr.value,
+ units_incr: incr.units
+ });
+ const result = await runSQL(sql, client);
+ await tagLog(client, result.rows, req.body.tag);
+ allRows.push(...result.rows);
+ }
+
+ if (allRows.length === 0) {
+ await client.query('ROLLBACK');
+ return res.status(400).json({
+ error: 'Nothing to scale — the target matches the current total, or the increment is zero'
+ });
+ }
+
+ await client.query('COMMIT');
+ committed = true;
+ const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
+ res.json({
+ rows,
+ rows_affected: rows.length,
+ slices_applied: applied,
+ ...(skipped.length ? { slices_skipped: skipped } : {})
+ });
+ } finally {
+ if (!committed) try { await client.query('ROLLBACK'); } catch {}
+ client.release();
}
-
- if (absValueIncr === 0 && absUnitsIncr === 0) {
- return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' });
- }
-
- const sql = applyTokens(ctx.sql, {
- fc_table: ctx.table,
- version_id: ctx.version.id,
- pf_user: esc(pf_user || ''),
- note: esc(note || ''),
- params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })),
- slice: esc(JSON.stringify(slice)),
- where_clause: whereClause,
- exclude_clause: excludeClause,
- value_incr: absValueIncr,
- units_incr: absUnitsIncr
- });
-
- const result = await runSQL(sql);
- const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
- res.json({ rows, rows_affected: rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
- // recode dimension values on a slice
+ // recode dimension values on one or more slices
// inserts negative rows to zero out the original, positive rows with new dimension values
router.post('/versions/:id/recode', async (req, res) => {
- const { pf_user, note, slice, set } = req.body;
- if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
- if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
+ const { pf_user, note, set, apply_mode } = req.body;
+ const slices = normalizeSlices(req.body);
+ if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
+ if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try {
const ctx = await getContext(parseInt(req.params.id), 'recode');
if (!guardOpen(ctx.version, res)) return;
+ assertSelective(slices, ctx);
- const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set);
+ const units = sliceUnits(slices, ctx, apply_mode);
- const sql = applyTokens(ctx.sql, {
- fc_table: ctx.table,
- version_id: ctx.version.id,
- pf_user: esc(pf_user || ''),
- note: esc(note || ''),
- params: esc(JSON.stringify({ slice, set })),
- slice: esc(JSON.stringify(slice)),
- where_clause: whereClause,
- exclude_clause: excludeClause,
- set_clause: setClause
- });
-
- const result = await runSQL(sql);
- const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
- res.json({ rows, rows_affected: rows.length });
+ const client = await pool.connect();
+ let committed = false;
+ try {
+ await client.query('BEGIN');
+ const allRows = [];
+ for (const unit of units) {
+ const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
+ const sql = applyTokens(ctx.sql, {
+ fc_table: ctx.table,
+ version_id: ctx.version.id,
+ pf_user: esc(pf_user || ''),
+ note: esc(note || ''),
+ params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
+ slice: esc(JSON.stringify(loggedSlice)),
+ where_clause: unit.where,
+ exclude_clause: excludeClause,
+ set_clause: setClause
+ });
+ const result = await runSQL(sql, client);
+ await tagLog(client, result.rows, req.body.tag);
+ allRows.push(...result.rows);
+ }
+ await client.query('COMMIT');
+ committed = true;
+ const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
+ res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
+ } finally {
+ if (!committed) try { await client.query('ROLLBACK'); } catch {}
+ client.release();
+ }
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
- // clone a slice as new business under new dimension values
+ // clone one or more slices as new business under new dimension values
// does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => {
- const { pf_user, note, slice, set, scale } = req.body;
- if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
- if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
+ const { pf_user, note, set, scale, apply_mode } = req.body;
+ const slices = normalizeSlices(req.body);
+ if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
+ if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try {
const ctx = await getContext(parseInt(req.params.id), 'clone');
if (!guardOpen(ctx.version, res)) return;
+ assertSelective(slices, ctx);
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
- const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set);
+ const units = sliceUnits(slices, ctx, apply_mode);
- const sql = applyTokens(ctx.sql, {
- fc_table: ctx.table,
- version_id: ctx.version.id,
- pf_user: esc(pf_user || ''),
- note: esc(note || ''),
- params: esc(JSON.stringify({ slice, set, scale: scaleFactor })),
- slice: esc(JSON.stringify(slice)),
- where_clause: whereClause,
- exclude_clause: excludeClause,
- set_clause: setClause,
- scale_factor: scaleFactor
- });
-
- const result = await runSQL(sql);
- const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
- res.json({ rows, rows_affected: rows.length });
+ const client = await pool.connect();
+ let committed = false;
+ try {
+ await client.query('BEGIN');
+ const allRows = [];
+ for (const unit of units) {
+ const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
+ const sql = applyTokens(ctx.sql, {
+ fc_table: ctx.table,
+ version_id: ctx.version.id,
+ pf_user: esc(pf_user || ''),
+ note: esc(note || ''),
+ params: esc(JSON.stringify({ slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode })),
+ slice: esc(JSON.stringify(loggedSlice)),
+ where_clause: unit.where,
+ exclude_clause: excludeClause,
+ set_clause: setClause,
+ scale_factor: scaleFactor
+ });
+ const result = await runSQL(sql, client);
+ await tagLog(client, result.rows, req.body.tag);
+ allRows.push(...result.rows);
+ }
+ await client.query('COMMIT');
+ committed = true;
+ const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
+ res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
+ } finally {
+ if (!committed) try { await client.query('ROLLBACK'); } catch {}
+ client.release();
+ }
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
diff --git a/routes/versions.js b/routes/versions.js
index ad02ba9..f874b07 100644
--- a/routes/versions.js
+++ b/routes/versions.js
@@ -114,6 +114,125 @@ ${colDefs},
}
});
+ // where this version's writes actually land: the physical forecast table,
+ // its current row count, and the source table rows are read from.
+ // Surfaced in the status bar so the write target is never a mystery.
+ router.get('/versions/:id/table-info', async (req, res) => {
+ try {
+ const verResult = await pool.query(`
+ SELECT v.id, v.name, v.status, s.schema, s.tname
+ FROM pf.version v
+ JOIN pf.source s ON s.id = v.source_id
+ WHERE v.id = $1
+ `, [req.params.id]);
+ if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
+
+ const v = verResult.rows[0];
+ const fc = fcTable(v.tname, v.id);
+ const [schema, table] = fc.split('.');
+
+ const existsResult = await pool.query(
+ `SELECT to_regclass($1) IS NOT NULL AS exists`, [fc]
+ );
+ const exists = existsResult.rows[0].exists;
+
+ let rows = null, byIter = [];
+ if (exists) {
+ const countResult = await pool.query(
+ `SELECT pf_iter, count(*)::int AS n FROM ${fc} GROUP BY pf_iter ORDER BY pf_iter`
+ );
+ byIter = countResult.rows;
+ rows = byIter.reduce((a, r) => a + r.n, 0);
+ }
+
+ res.json({
+ version_id: v.id,
+ version_name: v.name,
+ status: v.status,
+ source: `${v.schema}.${v.tname}`,
+ fc_table: fc,
+ fc_schema: schema,
+ fc_tname: table,
+ exists,
+ rows,
+ by_iter: byIter
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // Tags already used on this source, newest first — feeds the tag autocomplete.
+ // Scoped to the source rather than the version so an initiative name carries
+ // across versions, which is the point of naming it.
+ router.get('/sources/:id/tags', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT l.tag,
+ count(*)::int AS uses,
+ max(l.stamp) AS last_used
+ FROM pf.log l
+ JOIN pf.version v ON v.id = l.version_id
+ WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> ''
+ GROUP BY l.tag
+ ORDER BY max(l.stamp) DESC
+ `, [req.params.id]);
+ res.json(result.rows);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // Bridge: how this version got from its baseline to where it stands, grouped by
+ // initiative. Amounts come from the version's own forecast table, so the figures
+ // reconcile with the pivot rather than being recomputed from the log's params.
+ router.get('/versions/:id/bridge', async (req, res) => {
+ try {
+ const verResult = await pool.query(`
+ SELECT v.id, v.exclude_iters, s.schema, s.tname, s.id AS source_id
+ FROM pf.version v JOIN pf.source s ON s.id = v.source_id
+ WHERE v.id = $1
+ `, [req.params.id]);
+ if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
+ const v = verResult.rows[0];
+ const fc = fcTable(v.tname, v.id);
+
+ const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]);
+ if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] });
+
+ const colResult = await pool.query(
+ `SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]);
+ const valueCol = colResult.rows.find(c => c.role === 'value')?.cname;
+ const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname;
+ if (!valueCol) return res.status(400).json({ error: 'No value column configured' });
+
+ const excl = (v.exclude_iters || []).length
+ ? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})`
+ : 'TRUE';
+
+ const result = await pool.query(`
+ SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)'
+ ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag,
+ bool_or(t.pf_iter = 'baseline') AS is_baseline,
+ count(DISTINCT l.id)::int AS entries,
+ count(*)::int AS row_count,
+ round(sum(t."${valueCol}")::numeric, 2) AS value
+ ${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''}
+ FROM ${fc} t
+ LEFT JOIN pf.log l ON l.id = t.pf_logid
+ WHERE ${excl}
+ GROUP BY 1
+ ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id)
+ `);
+ res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
// update version name, description, or exclude_iters
router.put('/versions/:id', async (req, res) => {
const { name, description, exclude_iters } = req.body;
diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql
index b555002..cb53514 100644
--- a/setup_sql/01_schema.sql
+++ b/setup_sql/01_schema.sql
@@ -55,9 +55,15 @@ CREATE TABLE IF NOT EXISTS pf.log (
operation text NOT NULL, -- baseline | reference | scale | recode | clone
slice jsonb,
params jsonb,
- note text
+ note text,
+ tag text -- initiative label, e.g. 'reduce_spend'; groups
+ -- adjustments into a bridge from baseline to current
);
+-- adding tags to an install that predates them
+ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS tag text;
+CREATE INDEX IF NOT EXISTS log_tag_idx ON pf.log (tag) WHERE tag IS NOT NULL;
+
-- generated operation SQL per source, stored after col_meta is configured
CREATE TABLE IF NOT EXISTS pf.sql (
id serial PRIMARY KEY,
diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx
new file mode 100644
index 0000000..fa06ea0
--- /dev/null
+++ b/ui/src/components/BridgeView.jsx
@@ -0,0 +1,402 @@
+// Bridge (waterfall): how a version got from its baseline to where it stands,
+// one step per initiative tag.
+//
+// Data is computed from the Perspective table already in the browser rather than
+// from the /bridge endpoint, so the figures always reconcile with what the pivot
+// is showing — including when the view is scoped to the pivot's current filters.
+//
+// Colour is a POLARITY job, not a categorical one: increases and decreases are two
+// poles of one scale, with baseline and current as neutral anchors. Blue/red is the
+// validated diverging pair (CVD ΔE 21.6, normal-vision 32.3 against white);
+// green/red is avoided precisely because it is the classic CVD failure.
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+
+const UP = '#2a78d6' // increase
+const DOWN = '#e34948' // decrease
+const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
+const GRID = '#e5e7eb'
+const INK = '#374151'
+const INK_DIM = '#6b7280'
+
+const fmt = (n, dp = 2) =>
+ n == null || !isFinite(n) ? '—'
+ : n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })
+
+const fmtSigned = (n, dp = 2) =>
+ n == null || !isFinite(n) ? '—' : `${n > 0 ? '+' : n < 0 ? '−' : ''}${fmt(Math.abs(n), dp)}`
+
+// compact axis ticks — full precision belongs on the marks and in the table
+function fmtAxis(n) {
+ const a = Math.abs(n)
+ if (a >= 1e9) return `${(n / 1e9).toFixed(1)}B`
+ if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`
+ if (a >= 1e3) return `${(n / 1e3).toFixed(1)}k`
+ return String(Math.round(n))
+}
+
+function niceTicks(min, max, count = 5) {
+ if (!isFinite(min) || !isFinite(max) || min === max) return [min || 0]
+ const span = max - min
+ const raw = span / count
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)))
+ const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw) || mag * 10
+ const out = []
+ for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) out.push(t)
+ return out
+}
+
+// Turn raw forecast rows into the walk: baseline anchor, one floating step per
+// initiative tag, current anchor. Pure and exported so the arithmetic can be
+// checked against real data without a browser.
+export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIters = ['reference'] }) {
+ const excl = new Set(excludeIters)
+ const baseline = { value: 0, units: 0, rows: 0 }
+ const byTag = new Map()
+
+ for (const r of rows) {
+ const iter = r.pf_iter
+ if (excl.has(iter)) continue
+ const v = parseFloat(r[valueCol]) || 0
+ const u = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
+
+ if (iter === 'baseline') {
+ baseline.value += v; baseline.units += u; baseline.rows += 1
+ continue
+ }
+ const meta = logMeta[r.pf_logid] || {}
+ const tag = (meta.tag || '').trim()
+ const label = tag || (meta.note || '').trim() ||
+ `${(meta.operation || iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
+ const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`
+ const g = byTag.get(key) ||
+ { key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid }
+ 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) }
+ byTag.set(key, g)
+ }
+
+ const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
+
+ let running = baseline.value
+ const out = [{
+ key: 'baseline', label: 'Baseline', kind: 'anchor',
+ delta: baseline.value, start: 0, end: baseline.value,
+ units: baseline.units, rows: baseline.rows, entries: 1,
+ }]
+ for (const g of mid) {
+ const start = running
+ running += g.value
+ out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size })
+ }
+ out.push({
+ key: 'current', label: 'Current', kind: 'anchor',
+ delta: running, start: 0, end: running,
+ units: out.reduce((a, s) => a + (s.kind === 'anchor' ? 0 : s.units || 0), baseline.units),
+ rows: rows.filter(r => !excl.has(r.pf_iter)).length,
+ entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1,
+ })
+ return out
+}
+
+// Plot geometry, also pure: given the steps and a canvas size, where does each
+// bar and label land? Exported so collisions and overflow can be checked.
+export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 64, l: 68 }) {
+ const plotW = Math.max(120, width - PAD.l - PAD.r)
+ const plotH = H - PAD.t - PAD.b
+ const values = steps.flatMap(s => [s.start, s.end])
+ const rawMin = Math.min(0, ...values)
+ const rawMax = Math.max(0, ...values)
+ const span = (rawMax - rawMin) || 1
+ const yMin = rawMin - span * 0.08
+ const yMax = rawMax + span * 0.12
+ const y = (v) => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH
+
+ const n = steps.length || 1
+ const band = plotW / n
+ const barW = Math.max(10, Math.min(64, band - 14))
+ const bars = steps.map((s, i) => {
+ const x = PAD.l + band * i + (band - barW) / 2
+ const top = y(Math.max(s.start, s.end))
+ const bot = y(Math.min(s.start, s.end))
+ return { key: s.key, x, w: barW, top, h: Math.max(2, bot - top), labelY: top - 6 }
+ })
+ return { PAD, plotW, plotH, yMin, yMax, y, band, barW, bars, H, width }
+}
+
+export default function BridgeView({
+ open, onClose, tableRef, viewerRef, logMeta = {},
+ valueCol, unitsCol, colMeta = [], slices = [],
+ excludeIters = ['reference'], versionName,
+}) {
+ const hasSelection = slices.length > 0
+ // 'selection' | 'filtered' | 'all'
+ const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
+ const [asTable, setAsTable] = useState(false)
+ const [steps, setSteps] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [hover, setHover] = useState(null)
+ const [width, setWidth] = useState(880)
+ const boxRef = useRef(null)
+
+ // Build the steps: baseline anchor, one floating step per tag, current anchor.
+ const compute = useCallback(async () => {
+ if (!tableRef?.current || !valueCol) return
+ setLoading(true); setError(null)
+ try {
+ let rows
+ if (scope === 'selection') {
+ // 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
+ // view; rows matching more than one slice are counted once.
+ 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 seen = new Set()
+ rows = []
+ for (const sl of slices) {
+ const f = [
+ ...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]),
+ ...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]),
+ ]
+ if (!f.length) continue
+ const view = await tableRef.current.view({ filter: f })
+ const part = await view.to_json()
+ await view.delete()
+ for (const r of part) {
+ if (r.pf_id != null && seen.has(r.pf_id)) continue
+ if (r.pf_id != null) seen.add(r.pf_id)
+ rows.push(r)
+ }
+ }
+ } else {
+ let filter = []
+ if (scope === 'filtered' && viewerRef?.current) {
+ const cfg = await viewerRef.current.save()
+ filter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
+ }
+ const view = await tableRef.current.view(filter.length ? { filter } : {})
+ rows = await view.to_json()
+ await view.delete()
+ }
+
+ setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters }))
+ } catch (err) {
+ setError(err.message || String(err))
+ setSteps(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta])
+
+ useEffect(() => {
+ if (!hasSelection && scope === 'selection') setScope('filtered')
+ }, [hasSelection, scope])
+
+ useEffect(() => { if (open) compute() }, [open, compute])
+
+ useEffect(() => {
+ if (!open || !boxRef.current) return
+ const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
+ ro.observe(boxRef.current)
+ return () => ro.disconnect()
+ }, [open])
+
+ if (!open) return null
+
+ const geom = layoutSteps(steps || [{ start: 0, end: 0 }], width)
+ const { PAD, plotW, plotH, yMin, yMax, y, barW, H, bars } = geom
+ const xOf = (i) => bars[i]?.x ?? PAD.l
+
+ const ticks = niceTicks(yMin, yMax, 5)
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ Bridge
+ {versionName && {versionName} }
+
+ · {scope === 'selection'
+ ? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
+ : scope === 'filtered' ? "pivot's filters" : 'whole version'}
+
+
+
×
+
+
+ {/* controls — one row above the chart */}
+
+
Scope
+
+ {[
+ ['selection', hasSelection ? `Selection (${slices.length})` : 'Selection',
+ hasSelection ? 'The slices selected in the operation panel'
+ : 'Select one or more pivot rows first'],
+ ['filtered', "Pivot's filters", 'Everything the pivot currently shows'],
+ ['all', 'Whole version', 'Every row in the version, filters ignored'],
+ ].map(([v, l, title]) => (
+ setScope(v)} title={title}
+ disabled={v === 'selection' && !hasSelection}
+ 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'}`}>
+ {l}
+
+ ))}
+
+
+
setAsTable(t => !t)}
+ className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
+ {asTable ? 'Show chart' : 'Show table'}
+
+
+ {loading ? 'Computing…' : 'Refresh'}
+
+
+ {/* legend — identity is never colour alone, but say it anyway */}
+
+ {[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => (
+
+
+ {l}
+
+ ))}
+
+
+
+
+ {error &&
{error}
}
+ {!error && !steps &&
Computing…
}
+ {!error && steps && steps.length <= 2 && (
+
+ No adjustments in scope — the bridge shows the walk from baseline to current,
+ and this selection has only a baseline.
+
+ )}
+
+ {!error && steps && steps.length > 2 && !asTable && (
+
+
+ {/* recessive grid */}
+ {ticks.map(t => (
+
+
+
+ {fmtAxis(t)}
+
+
+ ))}
+
+ {steps.map((s, i) => {
+ const isAnchor = s.kind === 'anchor'
+ const up = s.delta >= 0
+ const fill = isAnchor ? ANCHOR : (up ? UP : DOWN)
+ const top = y(Math.max(s.start, s.end))
+ const bot = y(Math.min(s.start, s.end))
+ const h = Math.max(2, bot - top)
+ const x = xOf(i)
+ const on = hover?.key === s.key
+ return (
+ setHover({ ...s, x: x + barW / 2, y: top })}
+ onMouseLeave={() => setHover(null)}>
+ {/* connector to the next bar, drawn behind */}
+ {i < steps.length - 1 && (
+
+ )}
+ {/* hit target larger than the mark */}
+
+
+ {/* direct label: few bars, so every one is labelled */}
+
+ {isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
+
+
+ {s.label.length > 12 ? `${s.label.slice(0, 11)}…` : s.label}
+
+ {!isAnchor && s.entries > 1 && (
+
+ ×{s.entries}
+
+ )}
+ {!s.tagged && !isAnchor && (
+
+ untagged
+
+ )}
+
+ )
+ })}
+
+
+ {hover && (
+
+
{hover.label}
+
+ {hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)}
+
+ {hover.kind === 'step' && (
+
+ running → {fmt(hover.end)}
+
+ )}
+
+ {hover.rows} row{hover.rows === 1 ? '' : 's'}
+ {hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''}
+
+
+ )}
+
+ )}
+
+ {/* table view — the same numbers, at full precision */}
+ {!error && steps && steps.length > 2 && asTable && (
+
+
+
+ Step
+ {valueCol}
+ {unitsCol && {unitsCol} }
+ Running
+ Adjustments
+ Rows
+
+
+
+ {steps.map(s => (
+
+
+ {s.label}{!s.tagged && s.kind === 'step' && · untagged }
+
+
+ {s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
+
+ {unitsCol && (
+
+ {s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)}
+
+ )}
+ {fmt(s.end)}
+ {s.kind === 'step' ? s.entries : '—'}
+ {s.rows}
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx
new file mode 100644
index 0000000..528d5d1
--- /dev/null
+++ b/ui/src/components/OperationPanel.jsx
@@ -0,0 +1,683 @@
+// The operation workbench: what is selected, what it currently totals, and the
+// scale / recode / clone forms. Rendered by Forecast into one of three shells
+// (bottom dock, right rail, floating window).
+//
+// Two layout rules drive this file:
+// 1. Every value you are replacing sits on the same row as the input that
+// replaces it — current on the left, new on the right, delta after it.
+// Reading a total in one place and typing its replacement somewhere else
+// is what made the old panel hard to follow.
+// 2. Controls never stretch. The bottom dock is as wide as the window, so
+// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
+// left-aligned instead.
+
+import { useState } from 'react'
+
+const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums'
+const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono'
+
+function fmtNum(n, decimals = 2) {
+ if (n == null || !isFinite(n)) return '—'
+ return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
+}
+
+function fmtDelta(n, decimals = 2) {
+ if (n == null || !isFinite(n) || n === 0) return null
+ const sign = n > 0 ? '+' : '−'
+ return `${sign}${fmtNum(Math.abs(n), decimals)}`
+}
+
+function sliceLabel(s) {
+ const entries = Object.entries(s)
+ return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' · ') : '—'
+}
+
+// A light grouping with an optional caption — a rule between blocks in the
+// bottom dock, nothing but spacing elsewhere.
+function Block({ title, hint, horizontal, grow, children }) {
+ return (
+
+ {title && (
+
+ {title}
+ {hint && {hint} }
+
+ )}
+ {children}
+
+ )
+}
+
+function Button({ onClick, active, children, title }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function Segmented({ options, value, onChange }) {
+ return (
+
+ {options.map(([val, label, title]) => (
+ onChange(val)} active={value === val} title={title}>{label}
+ ))}
+
+ )
+}
+
+function Submit({ onClick, children, disabled }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ── 1. Selection ────────────────────────────────────────────────────────────
+function SelectionList({ slices, currentTotals, onRemove, onClear }) {
+ const multi = slices.length > 1
+ const perSlice = currentTotals?.perSlice || []
+ const valueCol = currentTotals?.valueCol
+
+ if (!slices.length) {
+ return (
+
+ Click a pivot row to select a slice.
+ Ctrl/⌘-click to add more.
+
+ )
+ }
+
+ return (
+
+
+
+
+ {slices.map((s, i) => (
+
+
+ {multi
+ ? {sliceLabel(s)}
+ : (
+
+ {Object.entries(s).map(([k, v]) => (
+
+ {k}
+ =
+ {v}
+
+ ))}
+
+ )}
+
+ {multi && valueCol && (
+
+ {fmtNum(perSlice[i]?.total?.value)}
+
+ )}
+
+ onRemove(i)} title="Remove from selection"
+ className="text-gray-500 hover:text-red-500 leading-none px-1">×
+
+
+ ))}
+
+
+
+
Clear selection
+
+ )
+}
+
+// Rows by pf_iter — useful context, but secondary to the numbers you are editing,
+// so it collapses out of the way.
+function IterBreakdown({ currentTotals }) {
+ const [open, setOpen] = useState(false)
+ const rows = currentTotals?.byIter || []
+ if (rows.length < 2) return null
+ const { valueCol, unitsCol } = currentTotals
+ return (
+
+
setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
+ {open ? '▾' : '▸'} breakdown by iter
+
+ {open && (
+
+
+ {rows.map(r => (
+
+ {r.iter}
+ {valueCol && {fmtNum(r.value)} }
+ {unitsCol && {fmtNum(r.units)} }
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+// ── 2. Scale ledger ─────────────────────────────────────────────────────────
+// One continuous statement: where the number came from, what it is now, and what
+// you want it to be — with the edit attached to the bottom of the same table
+// rather than lifted into a separate block.
+//
+// Baseline 1,000.00
+// Scale -20.00
+// ─────────────────────────
+// Current 1,070.00
+// ─────────────────────────
+// New value [ 2,000 ]
+// Change [ 930 ]
+// % change [ 86.9 ]
+//
+// The last three rows are all editable and all describe the same change: type in
+// any one and the other two follow. Whichever you typed in is what gets sent.
+const FIELDS = [
+ ['new', 'New value'],
+ ['change', 'Change'],
+ ['pct', '% change'],
+]
+
+// given the active edit for a measure, what do the three rows read?
+function derive(current, edit) {
+ const blank = { new: '', change: '', pct: '' }
+ if (!edit || edit.raw === '' || edit.raw == null) return blank
+ const n = parseFloat(edit.raw)
+ if (!isFinite(n)) return { ...blank, [edit.field]: edit.raw }
+
+ let next
+ if (edit.field === 'new') next = n
+ else if (edit.field === 'change') next = current + n
+ else next = current + current * n / 100
+
+ const change = next - current
+ const pct = current === 0 ? null : (change / Math.abs(current)) * 100
+ const out = {
+ new: fmtNum(next),
+ change: fmtNum(change),
+ pct: pct == null ? '—' : fmtNum(pct, 1),
+ }
+ out[edit.field] = edit.raw // keep what you typed exactly as typed
+ return out
+}
+
+function LedgerInput({ value, active, onChange, onFocus, suffix }) {
+ return (
+
+ onChange(e.target.value)}
+ onFocus={onFocus} placeholder="—"
+ className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums
+ ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />
+ {suffix && {suffix} }
+
+ )
+}
+
+function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
+ const valueCol = currentTotals?.valueCol
+ const unitsCol = currentTotals?.unitsCol
+ const total = currentTotals?.total || { value: 0, units: 0 }
+ const curPrice = total.units ? total.value / total.units : null
+ const entries = currentTotals?.byEntry || []
+
+ // The bridge: baseline, then one line per initiative, then whatever is untagged.
+ // Adjustments sharing a tag collapse into a single line, so the ledger reads as a
+ // walk from baseline to current rather than a list of log ids.
+ const lines = (() => {
+ const baseline = []
+ const loose = []
+ const byTag = new Map()
+ for (const e of entries) {
+ if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
+ const meta = logMeta[e.logid] || {}
+ const tag = (meta.tag || '').trim()
+ if (tag) {
+ const g = byTag.get(tag) ||
+ { key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid }
+ g.value += e.value || 0
+ g.units += e.units || 0
+ g.count += 1
+ g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first)
+ byTag.set(tag, g)
+ } else {
+ const op = meta.operation || e.iter || 'adjustment'
+ loose.push({
+ ...e, kind: 'entry', count: 1,
+ label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
+ })
+ }
+ }
+ const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
+ return [...baseline, ...tagged, ...loose]
+ })()
+ const perSlice = multi && applyMode === 'each'
+
+ // rows the pivot shows but this operation cannot write
+ const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 }
+ const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
+ const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
+ const grand = { value: total.value + excl.value, units: total.units + excl.units }
+ const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded'
+
+ // the basis decides which line the editable rows are measured from
+ const basisOf = (key) => {
+ if (!onTotal) return key === 'price' ? curPrice : total[key]
+ if (key === 'price') return grand.units ? grand.value / grand.units : null
+ return grand[key]
+ }
+
+ // measure columns, in ledger order
+ const measures = [
+ valueCol && { key: 'value', label: valueCol, current: total.value, dp: 2 },
+ unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 2 },
+ (valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 4, hint: 'holds units' },
+ ].filter(Boolean)
+
+ const derived = Object.fromEntries(
+ measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key])])
+ )
+
+ const setEdit = (key, field, raw) =>
+ setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
+
+ // focusing a different row hands that measure's edit to the focused row,
+ // carrying across whatever it currently reads
+ const focusRow = (key, field) =>
+ setScaleInputs(prev => {
+ const cur = prev[key]
+ if (cur && cur.field === field) return prev
+ const shown = derived[key]?.[field] ?? ''
+ const raw = shown === '—' ? '' : String(shown).replace(/,/g, '')
+ return { ...prev, [key]: { field, raw: cur ? raw : '' } }
+ })
+
+ const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
+ const rule =
+
+ return (
+
+
+
+
+
+ {measures.map(m => (
+
+ {m.label}
+ {m.hint && · {m.hint} }
+
+ ))}
+
+
+
+ {/* the walk from baseline to current, by initiative */}
+ {lines.map(e => (
+
+
+ {e.kind === 'tag' && ▪ }
+ {e.label}
+ {e.kind === 'tag' && e.count > 1 && ×{e.count} }
+
+ {measures.map(m => (
+
+ {m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)}
+
+ ))}
+
+ ))}
+
+ {rule}{measures.map(m =>
)}
+
+
+
+ {hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
+
+ {measures.map(m => (
+ {fmtNum(m.current, m.dp)}
+ ))}
+
+
+ {/* Rows the pivot shows but operations cannot write. Listed so the
+ panel's figures reconcile with what the grid displays. */}
+ {hasExcl && (
+
+
+ {exclName} · fixed
+
+ {measures.map(m => (
+
+ {m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)}
+
+ ))}
+
+ )}
+
+ {hasExcl && (
+
+ Selected total
+ {measures.map(m => (
+
+ {m.key === 'price'
+ ? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
+ : fmtNum(grand[m.key], m.dp)}
+
+ ))}
+
+ )}
+
+ {rule}{measures.map(m =>
)}
+
+ {/* the edit — three equivalent ways to say the same thing */}
+ {FIELDS.map(([field, label]) => (
+
+
+ {label}{perSlice && field === 'new' ? ' (each)' : ''}
+
+ {measures.map(m => {
+ const active = scaleInputs[m.key]?.field === field
+ return (
+
+ setEdit(m.key, field, raw)}
+ onFocus={() => focusRow(m.key, field)}
+ suffix={field === 'pct' ? '%' : null}
+ />
+
+ )
+ })}
+
+ ))}
+
+
+
+ {hasExcl && (
+
+
+ Target applies to
+
+
+
+ {onTotal
+ ? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.`
+ : `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}
+
+
+ )}
+
+ {perSlice && (
+
+ Applied to each slice separately — the figures above are combined totals,
+ so each slice's own change will differ.
+
+ )}
+
+ )
+}
+
+// ── 2b. Recode / clone form ─────────────────────────────────────────────────
+// Same pairing: the dimension's current value sits beside the box that replaces it.
+function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra }) {
+ const multi = slices.length > 1
+ const first = slices[0] || {}
+ return (
+
+ )
+}
+
+// Recode and clone change dimensions rather than amounts, but you still want to
+// see how much is on the move — and for clone, what it becomes after scaling.
+function MovingTotal({ currentTotals, verb, factor }) {
+ const t = currentTotals?.total
+ if (!t) return null
+ const { valueCol, unitsCol } = currentTotals
+ const scaled = factor != null && factor !== 1
+ return (
+
+ {verb}{' '}
+ {valueCol && {fmtNum(t.value)} }
+ {valueCol && {valueCol} }
+ {unitsCol && <>
+ ·
+ {fmtNum(t.units)}
+ {unitsCol}
+ >}
+ {scaled && valueCol && <>
+ →
+ {fmtNum(t.value * factor)}
+ >}
+
+ )
+}
+
+// ── Apply mode ──────────────────────────────────────────────────────────────
+function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
+ const options = op === 'scale'
+ ? [
+ ['prorate', 'Together', `One pool of ${count} slices — a target is the new combined total, and each slice keeps its share of the mix.`],
+ ['each', 'Each', `All ${count} slices independently — every slice reaches the target on its own.`],
+ ]
+ : [
+ ['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`],
+ ['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`],
+ ]
+ const active = options.find(([v]) => v === applyMode)
+ return (
+
+
[v, l, t])} />
+ {active && {active[2]}
}
+
+ )
+}
+
+function RequestPreview({ payload }) {
+ const [open, setOpen] = useState(false)
+ if (!payload) return null
+ return (
+
+
setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
+ {open ? '▾' : '▸'} request
+
+ {open && (
+
+ {JSON.stringify(payload, null, 2)}
+
+ )}
+
+ )
+}
+
+export default function OperationPanel({
+ dock,
+ slices, setSlices, distinctSlices,
+ applyMode, setApplyMode,
+ currentTotals,
+ activeOp, setActiveOp,
+ scaleInputs, setScaleInputs,
+ targetBasis, setTargetBasis,
+ opTag, setOpTag, knownTags = [], logMeta = {},
+ scaleNote, setScaleNote,
+ recodeSet, setRecodeSet,
+ recodeNote, setRecodeNote,
+ cloneSet, setCloneSet,
+ cloneScale, setCloneScale,
+ cloneNote, setCloneNote,
+ dimCols, lookupDerivedCols,
+ buildPayload, submitOp,
+}) {
+ const hasSlice = slices.length > 0
+ const multi = slices.length > 1
+ const horizontal = dock === 'bottom'
+
+ const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
+ const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
+ const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
+
+ return (
+
+
+
+ {/* Cells that differ only by a column operations cannot filter on collapse
+ to the same slice — say so, rather than implying more reach than there is */}
+ {distinctSlices != null && distinctSlices < slices.length && (
+
+ {slices.length} cells selected, but they cover {distinctSlices} distinct{' '}
+ {distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column
+ operations cannot target. The duplicates are applied once.
+
+ )}
+ setSlices(prev => prev.filter((_, x) => x !== i))}
+ onClear={() => setSlices([])}
+ />
+
+
+ {hasSlice && (
+
+
+
+
+ {['scale', 'recode', 'clone'].map(op => (
+ setActiveOp(op)} active={activeOp === op}>
+ {op}
+
+ ))}
+
+ {multi && (
+
+ )}
+
+
+ {activeOp === 'scale' && (
+
+ )}
+ {activeOp === 'recode' && (
+
} />
+ )}
+ {activeOp === 'clone' && (
+
+
+ scale cloned rows by
+ setCloneScale(e.target.value)} className={INPUT} />
+
+
+
+ } />
+ )}
+
+
+ )}
+
+ {hasSlice && (
+
+
+ {/* Tag first: it is the field that gives an adjustment meaning later,
+ in the ledger and in the bridge. Completes from initiatives already
+ used on this source; free text is still accepted. */}
+
+ tag
+ setOpTag(e.target.value)}
+ list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
+ className={`${TEXT} w-48`} />
+ {opTag.trim() && (
+ setOpTag('')} title="Clear tag"
+ className="text-gray-500 hover:text-red-500 leading-none px-1">×
+ )}
+
+ {knownTags.length > 0 && (
+
+ {knownTags.slice(0, 6).map(t => (
+ setOpTag(t.tag)}
+ title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
+ className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
+ opTag.trim() === t.tag
+ ? 'bg-blue-600 border-blue-600 text-white'
+ : 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
+ {t.tag}
+
+ ))}
+
+ )}
+
+
+ note
+ setNote(e.target.value)} placeholder="optional" className={TEXT} />
+
+
submitOp(activeOp)}>{OP_LABEL[activeOp]}
+
+
+
+ )}
+
+ )
+}
diff --git a/ui/src/components/StatusBar.jsx b/ui/src/components/StatusBar.jsx
index 55784cd..995d9d6 100644
--- a/ui/src/components/StatusBar.jsx
+++ b/ui/src/components/StatusBar.jsx
@@ -1,3 +1,4 @@
+import { useState, useEffect, useCallback } from 'react'
import useTheme from '../theme.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
@@ -5,8 +6,40 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
const showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
+ const [info, setInfo] = useState(null)
+ const [showInfo, setShow] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ const refreshInfo = useCallback(async () => {
+ if (!versionId || !showVersion) { setInfo(null); return }
+ try {
+ const r = await fetch(`/api/versions/${versionId}/table-info`)
+ setInfo(r.ok ? await r.json() : null)
+ } catch { setInfo(null) }
+ }, [versionId, showVersion])
+
+ useEffect(() => { refreshInfo() }, [refreshInfo])
+
+ // operations broadcast this after a write so the row count stays honest
+ useEffect(() => {
+ const onChange = () => refreshInfo()
+ window.addEventListener('pf-data-changed', onChange)
+ return () => window.removeEventListener('pf-data-changed', onChange)
+ }, [refreshInfo])
+
+ async function copyTable() {
+ if (!info?.fc_table) return
+ try {
+ await navigator.clipboard.writeText(info.fc_table)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1200)
+ } catch {}
+ }
+
+ const fmt = (n) => n == null ? '—' : n.toLocaleString()
+
return (
-
+
Source
)}
+
+ {/* write target — the physical table every operation appends to */}
+ {info && (
+ <>
+ |
+ writes to
+ setShow(true)}
+ onMouseLeave={() => setShow(false)}
+ className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
+ info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
+ }`}
+ title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
+ >
+ {copied ? 'copied!' : info.fc_table}
+
+
+ {info.exists ? `${fmt(info.rows)} rows` : 'not created'}
+
+
+ {showInfo && (
+
+
Write target
+
+ table
+ {info.fc_table}
+ reads from
+ {info.source}
+ total rows
+ {fmt(info.rows)}
+
+ {info.by_iter?.length > 0 && (
+ <>
+
Rows by iter
+
+
+ {info.by_iter.map(r => (
+
+ {r.pf_iter}
+ {fmt(r.n)}
+
+ ))}
+
+
+ >
+ )}
+
+ )}
+ >
+ )}
>
)}
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx
index 8000588..1b31ad4 100644
--- a/ui/src/views/Forecast.jsx
+++ b/ui/src/views/Forecast.jsx
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
+import OperationPanel from '../components/OperationPanel.jsx'
+import BridgeView from '../components/BridgeView.jsx'
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
// WASM in the build, so the version is fixed by package-lock.json. Do NOT go back to
@@ -26,7 +28,7 @@ function cleanLayout(cfg, validCols) {
return c
}
-export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
+export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
const { dark } = useTheme()
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
@@ -39,15 +41,24 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const [showSaveAs, setShowSaveAs] = useState(false)
const [saveAsName, setSaveAsName] = useState('')
- // operation panel
- const [slice, setSlice] = useState({})
+ // operation panel — a selection is a LIST of slices; one entry is the common case
+ const [slices, setSlices] = useState([])
+ const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
const [activeOp, setActiveOp] = useState('scale')
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
- const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
- const [scaleValue, setScaleValue] = useState('')
- const [scaleUnits, setScaleUnits] = useState('')
- const [scalePrice, setScalePrice] = useState('')
- const [scalePct, setScalePct] = useState(false)
+ // One entry per measure ('value' | 'units' | 'price'), each { field, raw } where
+ // field is 'new' | 'change' | 'pct'. There is no separate mode toggle: the row
+ // you type in decides how the number is interpreted, and the other two rows of
+ // that measure are derived from it.
+ const [scaleInputs, setScaleInputs] = useState({})
+ // what a target/percentage is measured against: the rows this operation can
+ // write, or everything the pivot shows for the slice (excluded rows included)
+ const [targetBasis, setTargetBasis] = useState('selected')
+ // one tag spans all three operations — an initiative is not per-operation
+ const [opTag, setOpTag] = useState('')
+ const [knownTags, setKnownTags] = useState([])
+ // logid -> { tag, note, operation }, so ledger lines can name themselves
+ const [logMeta, setLogMeta] = useState({})
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
const [recodeNote, setRecodeNote] = useState('')
@@ -55,13 +66,100 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const [cloneScale, setCloneScale] = useState('1')
const [cloneNote, setCloneNote] = useState('')
- const [panelWidth, setPanelWidth] = useState(224)
+ // panel placement: 'bottom' | 'right' | 'float'. Persisted so it stays where you put it.
+ const [dock, setDock] = useState(() => localStorage.getItem('pf_dock') || 'bottom')
+ const [panelOpen, setPanelOpen] = useState(() => localStorage.getItem('pf_panel_open') !== 'closed')
+ const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360)
+ const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260)
+ useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock])
+ useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
+
+ // Esc closes the panel — the usual way out of the floating window
+ useEffect(() => {
+ if (!panelOpen) return
+ const onKey = (e) => {
+ if (e.key !== 'Escape') return
+ const t = e.target
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
+ setPanelOpen(false)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [panelOpen])
+ useEffect(() => { localStorage.setItem('pf_panel_w', String(panelWidth)) }, [panelWidth])
+ useEffect(() => { localStorage.setItem('pf_panel_h', String(panelHeight)) }, [panelHeight])
+
+ // Floating panel geometry — free position and size, unlike the docked modes
+ // which only have one adjustable axis. null until first opened, so the default
+ // can be placed against the actual viewport.
+ const [floatRect, setFloatRect] = useState(() => {
+ try {
+ const saved = JSON.parse(localStorage.getItem('pf_float_rect') || 'null')
+ if (saved && saved.w > 0 && saved.h > 0) return saved
+ } catch {}
+ return null
+ })
+ useEffect(() => {
+ if (floatRect) localStorage.setItem('pf_float_rect', JSON.stringify(floatRect))
+ }, [floatRect])
+
+ // keep at least a corner of the window on screen, whatever the viewport does
+ function clampRect(r) {
+ const w = Math.max(300, Math.min(r.w, window.innerWidth - 16))
+ const h = Math.max(180, Math.min(r.h, window.innerHeight - 16))
+ const KEEP = 100 // px of the window that must stay reachable
+ return {
+ w, h,
+ x: Math.max(KEEP - w, Math.min(r.x, window.innerWidth - KEEP)),
+ y: Math.max(0, Math.min(r.y, window.innerHeight - 40)),
+ }
+ }
+
+ useEffect(() => {
+ if (dock !== 'float' || floatRect) return
+ const w = 440, h = Math.min(460, window.innerHeight - 120)
+ setFloatRect(clampRect({ x: window.innerWidth - w - 24, y: window.innerHeight - h - 24, w, h }))
+ }, [dock, floatRect])
+
+ useEffect(() => {
+ if (dock !== 'float') return
+ const onResize = () => setFloatRect(r => r ? clampRect(r) : r)
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [dock])
+
+ // shared pointer-drag loop for both moving and resizing the floating window
+ function startFloatDrag(e, apply) {
+ if (!floatRect) return
+ e.preventDefault()
+ const sx = e.clientX, sy = e.clientY
+ const r0 = floatRect
+ const onMove = (ev) => setFloatRect(clampRect(apply(r0, ev.clientX - sx, ev.clientY - sy)))
+ const onUp = () => {
+ window.removeEventListener('mousemove', onMove)
+ window.removeEventListener('mouseup', onUp)
+ document.body.style.userSelect = ''
+ }
+ document.body.style.userSelect = 'none'
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ }
+
+ const onFloatMove = (e) => {
+ if (e.target.closest('button')) return // let the dock switcher work
+ startFloatDrag(e, (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy }))
+ }
+ const onFloatResize = (e) => {
+ e.stopPropagation()
+ startFloatDrag(e, (r, dx, dy) => ({ ...r, w: r.w + dx, h: r.h + dy }))
+ }
// history modal
const [showLog, setShowLog] = useState(false)
+ const [showBridge, setShowBridge] = useState(false)
const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false)
- const [editingNote, setEditingNote] = useState(null) // { id, text }
+ const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', text }
const [undoingId, setUndoingId] = useState(null)
const viewerRef = useRef(null)
@@ -70,13 +168,58 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const colMetaRef = useRef([])
const expandDepthRef = useRef(null)
const initIdRef = useRef(0)
+ const modifierRef = useRef(false)
+
+ // Perspective's ROLLUP view contains every level of the hierarchy; view.set_depth()
+ // is the only thing hiding the deeper ones, and it lives on the view rather than in
+ // the saved config. The viewer rebuilds its view when it redraws — which its
+ // Intersection/ResizeObserver triggers when you switch back to the tab — and the
+ // fresh view has no depth set, so the whole tree appears expanded. Re-apply the
+ // depth we last set once the redraw has settled.
+ useEffect(() => {
+ let queued = false
+ const reapply = () => {
+ if (document.visibilityState !== 'visible') return
+ if (expandDepthRef.current == null || !viewerRef.current) return
+ if (queued) return
+ queued = true
+ // let the viewer finish its own redraw first, or it will draw over us
+ requestAnimationFrame(() => setTimeout(async () => {
+ queued = false
+ const d = expandDepthRef.current
+ if (d == null) return
+ try { await applyDepth(d) } catch {}
+ }, 60))
+ }
+ document.addEventListener('visibilitychange', reapply)
+ window.addEventListener('focus', reapply)
+ window.addEventListener('pageshow', reapply)
+ return () => {
+ document.removeEventListener('visibilitychange', reapply)
+ window.removeEventListener('focus', reapply)
+ window.removeEventListener('pageshow', reapply)
+ }
+ }, [])
+
+ // perspective-click fires as a CustomEvent with no modifier state of its own,
+ // so record it from the mousedown that precedes it
+ useEffect(() => {
+ const onDown = (e) => { modifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey }
+ window.addEventListener('mousedown', onDown, true)
+ return () => window.removeEventListener('mousedown', onDown, true)
+ }, [])
function onDragStart(e) {
e.preventDefault()
- const startX = e.clientX
- const startW = panelWidth
- const onMove = (ev) => setPanelWidth(Math.max(160, Math.min(480, startW - (ev.clientX - startX))))
- const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
+ const vertical = dock === 'bottom'
+ const start = vertical ? e.clientY : e.clientX
+ const startVal = vertical ? panelHeight : panelWidth
+ const onMove = (ev) => {
+ const delta = (vertical ? ev.clientY : ev.clientX) - start
+ if (vertical) setPanelHeight(Math.max(140, Math.min(window.innerHeight - 160, startVal - delta)))
+ else setPanelWidth(Math.max(260, Math.min(760, startVal - delta)))
+ }
+ const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
@@ -94,24 +237,32 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}, [dark, versionId])
useEffect(() => {
- const blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
+ const keys = new Set(slices.flatMap(sl => Object.keys(sl)))
+ const blank = Object.fromEntries([...keys].map(k => [k, '']))
setRecodeSet(blank)
setCloneSet(blank)
- setScaleValue('')
- setScaleUnits('')
- setScalePrice('')
- if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
+ setScaleInputs({})
+ if (slices.length > 0) fetchCurrentTotals(slices)
else setCurrentTotals(null)
- }, [slice])
+ }, [slices])
- async function fetchCurrentTotals(sliceObj) {
- if (!tableRef.current) return
+ // Totals for the current selection. Perspective view filters are AND-only, so a
+ // union of slices can't be expressed as one view — each slice gets its own view
+ // and the results are summed. Per-slice totals are kept so the panel can show
+ // exactly what is selected rather than one opaque number.
+ async function fetchCurrentTotals(sliceList) {
+ if (!tableRef.current || !sliceList.length) { setCurrentTotals(null); return }
const valueCol = colMetaRef.current.find(c => c.role === 'value')?.cname
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
if (!valueCol && !unitsCol) return
- try {
- const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
- const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
+
+ const version = versions.find(v => String(v.id) === String(versionId))
+ const excludeIters = new Set(version?.exclude_iters || ['reference'])
+ const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
+ const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
+ const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
+
+ async function totalsFor(sliceObj) {
const filters = [
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
@@ -119,32 +270,133 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
...Object.entries(sliceObj)
.filter(([col]) => dateNames.has(col))
.map(([col, val]) => [col, '==', Number(val)]),
- ['pf_iter', '!=', 'reference'],
]
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const buckets = new Map()
+ // one ledger line per change: the baseline, then each operation that has
+ // touched this slice since, keyed by its log id so they read in the order
+ // they were applied
+ const entries = new Map()
+ // rows the pivot shows but operations cannot write (usually 'reference').
+ // Kept separate rather than filtered away: the grid total includes them,
+ // so the panel has to account for them or the two disagree.
+ const excluded = { value: 0, units: 0, rows: 0 }
for (const r of rows) {
const k = r.pf_iter || '?'
+ const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0
+ const uni = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
+
+ if (excludeIters.has(k)) {
+ excluded.value += val
+ excluded.units += uni
+ excluded.rows += 1
+ continue
+ }
+
const t = buckets.get(k) || { value: 0, units: 0 }
- if (valueCol) t.value += parseFloat(r[valueCol]) || 0
- if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
+ t.value += val
+ t.units += uni
buckets.set(k, t)
+
+ const ek = k === 'baseline' ? 'baseline' : `log:${r.pf_logid}`
+ const e = entries.get(ek) || { key: ek, iter: k, logid: r.pf_logid ?? null, value: 0, units: 0 }
+ e.value += val
+ e.units += uni
+ entries.set(ek, e)
}
- const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
- const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
- setCurrentTotals({ byIter, total, valueCol, unitsCol })
+ const total = byIter.reduce(
+ (acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
+ { value: 0, units: 0 })
+ return { byIter, byEntry: Array.from(entries.values()), total, excluded, rows: rows.length }
+ }
+
+ try {
+ const perSlice = []
+ for (const sl of sliceList) {
+ perSlice.push({ slice: sl, ...(await totalsFor(sl)) })
+ }
+
+ // roll the per-slice iter buckets up into one combined breakdown
+ const combined = new Map()
+ for (const ps of perSlice) {
+ for (const r of ps.byIter) {
+ const t = combined.get(r.iter) || { value: 0, units: 0 }
+ t.value += r.value || 0
+ t.units += r.units || 0
+ combined.set(r.iter, t)
+ }
+ }
+ const byIter = Array.from(combined, ([iter, t]) => ({ iter, ...t }))
+ .sort((a, b) => {
+ const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
+ })
+ const total = byIter.reduce(
+ (acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
+ { value: 0, units: 0 })
+
+ // same rollup for the ledger: baseline first, then adjustments by log order
+ const entryMap = new Map()
+ for (const ps of perSlice) {
+ for (const e of ps.byEntry || []) {
+ const t = entryMap.get(e.key) || { ...e, value: 0, units: 0 }
+ t.value += e.value || 0
+ t.units += e.units || 0
+ entryMap.set(e.key, t)
+ }
+ }
+ const byEntry = Array.from(entryMap.values()).sort((a, b) => {
+ if (a.key === 'baseline') return -1
+ if (b.key === 'baseline') return 1
+ return (a.logid ?? 0) - (b.logid ?? 0)
+ })
+
+ const excluded = perSlice.reduce(
+ (acc, ps) => ({
+ value: acc.value + (ps.excluded?.value || 0),
+ units: acc.units + (ps.excluded?.units || 0),
+ rows: acc.rows + (ps.excluded?.rows || 0),
+ }), { value: 0, units: 0, rows: 0 })
+
+ setCurrentTotals({
+ byIter, byEntry, total, excluded, valueCol, unitsCol, perSlice,
+ excludedIters: [...excludeIters],
+ })
} catch {
setCurrentTotals(null)
}
}
+ // Ledger lines show a tag or note rather than a bare log id, and the tag box
+ // completes from initiatives already used on this source.
+ async function refreshLogMeta(vid) {
+ if (!vid) { setLogMeta({}); return }
+ try {
+ const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
+ const map = {}
+ for (const e of entries) map[e.id] = { tag: e.tag || null, note: e.note || null, operation: e.operation }
+ setLogMeta(map)
+ } catch { setLogMeta({}) }
+ }
+
+ async function refreshTags(sid) {
+ if (!sid) { setKnownTags([]); return }
+ try {
+ const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json())
+ setKnownTags(Array.isArray(rows) ? rows : [])
+ } catch { setKnownTags([]) }
+ }
+
+ useEffect(() => { refreshLogMeta(versionId) }, [versionId])
+ useEffect(() => { refreshTags(sourceId) }, [sourceId])
+
function loadLayouts(vid) {
const stored = localStorage.getItem(LAYOUTS_KEY(vid))
setLayouts(stored ? JSON.parse(stored) : [])
@@ -158,7 +410,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
- setSlice({})
+ setSlices([])
expandDepthRef.current = null
try {
const [dataResult, meta] = await Promise.all([
@@ -269,21 +521,38 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
- // click → slice via event filters (Perspective encodes row position as [col,'==',val] triples)
+ // click → slice via event filters (Perspective encodes row position as [col,'==',val] triples).
+ // Plain click replaces the selection; ctrl/cmd/shift-click toggles a slice in or
+ // out of it, which is what makes multi-slice operations possible.
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
viewer._pspClick = async (e) => {
const detail = e.detail || {}
if (!detail.row) return
const config = await viewer.save()
if (!(config.group_by || []).length) return
- const eventFilters = (detail.config || {}).filter || []
- const s = {}
- eventFilters.forEach(([col, op, val]) => {
- if (op === '==' && val != null) s[col] = String(val)
- })
- if (Object.keys(s).length > 0) setSlice(s)
+ const s = sliceFromFilters((detail.config || {}).filter || [])
+ if (!Object.keys(s).length) return
+ // the CustomEvent carries no modifier flags, so read them off the
+ // mousedown that produced it (captured on window below)
+ const additive = modifierRef.current
+ setSlices(prev => additive ? toggleSlice(prev, s) : [s])
}
viewer.addEventListener('perspective-click', viewer._pspClick)
+
+ // Region selection (drag across rows) arrives as perspective-select, one event
+ // per row with a `selected` toggle. The payload is built WASM-side, so treat it
+ // defensively: if no filters can be extracted, fall through and change nothing.
+ if (viewer._pspSelect) viewer.removeEventListener('perspective-select', viewer._pspSelect)
+ viewer._pspSelect = (e) => {
+ const detail = e.detail || {}
+ const configs = detail.selected ? detail.insertConfigs : detail.removeConfigs
+ const filters = (Array.isArray(configs) ? configs : [])
+ .flatMap(c => (c && Array.isArray(c.filter)) ? c.filter : [])
+ const s = sliceFromFilters(filters)
+ if (!Object.keys(s).length) return
+ setSlices(prev => detail.selected ? addSlice(prev, s) : removeSlice(prev, s))
+ }
+ viewer.addEventListener('perspective-select', viewer._pspSelect)
setLargeDataset(false)
} catch (err) {
@@ -387,38 +656,22 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}
async function submitOp(op) {
- if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
- const effectiveSlice = buildEffectiveSlice(slice)
- if (!Object.keys(effectiveSlice).length) { flash('No dimension or date columns in slice — check col_meta', 'error'); return }
- let body = { pf_user: 'admin', slice: effectiveSlice }
+ if (!slices.length) { flash('Select a slice first', 'error'); return }
+ const body = buildPayload(op)
+ 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
+ }
if (op === 'scale') {
- let vi = null, ui = null
- if (scaleMode === 'target') {
- const curValue = currentTotals?.total?.value
- const curUnits = currentTotals?.total?.units
- if (scalePrice !== '' && curUnits != null && curValue != null) {
- // hold units constant; new value = price × current units
- vi = (parseFloat(scalePrice) * curUnits) - curValue
- }
- if (scaleValue !== '' && curValue != null)
- vi = parseFloat(scaleValue) - curValue
- if (scaleUnits !== '' && curUnits != null)
- ui = parseFloat(scaleUnits) - curUnits
- } else {
- if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
- if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
- }
- if (vi == null && ui == null) { flash('Enter a target or increment', 'error'); return }
- body = { ...body, note: scaleNote, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
- } else if (op === 'recode') {
- const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
- if (!Object.keys(set).length) { flash('Enter at least one new dimension value', 'error'); return }
- body = { ...body, note: recodeNote, set }
- } else if (op === 'clone') {
- const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
- if (!Object.keys(set).length) { flash('Enter at least one override value', 'error'); return }
- body = { ...body, note: cloneNote, set, scale: parseFloat(cloneScale) || 1 }
+ const has = ['target_value','target_units','target_price',
+ 'value_incr','units_incr','value_pct','units_pct']
+ .some(k => body[k] !== undefined)
+ if (!has) { flash('Enter a target or increment', 'error'); return }
+ }
+ if ((op === 'recode' || op === 'clone') && !Object.keys(body.set || {}).length) {
+ flash(op === 'recode' ? 'Enter at least one new dimension value' : 'Enter at least one override value', 'error')
+ return
}
try {
@@ -428,8 +681,18 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
- flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
- if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
+ const n = data.slices_applied > 1 ? ` across ${data.slices_applied} slices` : ''
+ // a slice that matched no rows, or was already at its target, writes nothing —
+ // say so rather than reporting a silent partial success
+ const skipped = data.slices_skipped?.length
+ ? ` — ${data.slices_skipped.length} slice${data.slices_skipped.length === 1 ? '' : 's'} unchanged (no rows, or already on target)`
+ : ''
+ flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows${n}${skipped}`, skipped ? 'warn' : 'ok')
+ // let the status bar re-read the forecast table's row count
+ window.dispatchEvent(new CustomEvent('pf-data-changed'))
+ refreshLogMeta(versionId)
+ if (opTag.trim()) refreshTags(sourceId)
+ if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
@@ -465,25 +728,64 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}
function buildPayload(op) {
- if (!Object.keys(slice).length) return null
- const effectiveSlice = buildEffectiveSlice(slice)
- let body = { pf_user: 'admin', slice: effectiveSlice }
+ if (!slices.length) return null
+ // 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
+ // under apply_mode 'each'. Collapse duplicates before they reach the API.
+ const seen = new Set()
+ const effectiveSlices = []
+ for (const sl of slices) {
+ const eff = buildEffectiveSlice(sl)
+ const key = JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]]))
+ if (seen.has(key)) continue
+ seen.add(key)
+ effectiveSlices.push(eff)
+ }
+ // apply_mode only changes the maths when more than one slice is selected
+ let body = {
+ pf_user: 'admin',
+ tag: opTag.trim() || undefined,
+ slices: effectiveSlices,
+ ...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
+ }
if (op === 'scale') {
- let vi = null, ui = null
- if (scaleMode === 'target') {
- const curValue = currentTotals?.total?.value
- const curUnits = currentTotals?.total?.units
- if (scalePrice !== '' && curUnits != null && curValue != null)
- vi = (parseFloat(scalePrice) * curUnits) - curValue
- if (scaleValue !== '' && curValue != null)
- vi = parseFloat(scaleValue) - curValue
- if (scaleUnits !== '' && curUnits != null)
- ui = parseFloat(scaleUnits) - curUnits
- } else {
- if (scaleValue !== '') vi = parseFloat(scaleValue)
- if (scaleUnits !== '') ui = parseFloat(scaleUnits)
+ body = { ...body, note: scaleNote || undefined }
+ const adj = currentTotals?.total || { value: 0, units: 0 }
+ const excl = currentTotals?.excluded || { value: 0, units: 0 }
+ // only meaningful when the slice actually contains rows operations cannot write
+ const hasExcl = excl.value !== 0 || excl.units !== 0
+ const useTotal = targetBasis !== 'adjustable' && hasExcl
+ if (useTotal) body.target_basis = 'selected'
+ const cur = useTotal
+ ? { value: adj.value + excl.value, units: adj.units + excl.units }
+ : adj
+ const curPrice = cur.units ? cur.value / cur.units : null
+ const num = (raw) => {
+ const n = parseFloat(raw)
+ return (raw !== '' && raw != null && isFinite(n)) ? n : null
+ }
+
+ const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
+ const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
+
+ if (vn != null) {
+ if (v.field === 'new') body.target_value = vn
+ else if (v.field === 'change') body.value_incr = vn
+ else body.value_pct = vn
+ }
+ if (un != null) {
+ if (u.field === 'new') body.target_units = un
+ else if (u.field === 'change') body.units_incr = un
+ else body.units_pct = un
+ }
+ // price has no increment form server-side, so every price edit is resolved
+ // to an absolute target price here
+ if (pn != null && curPrice != null) {
+ body.target_price = p.field === 'new' ? pn
+ : p.field === 'change' ? curPrice + pn
+ : curPrice * (1 + pn / 100)
}
- body = { ...body, note: scaleNote || undefined, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set }
@@ -496,7 +798,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
function flash(text, type = 'ok') {
setMsg({ text, type })
- if (type !== 'error') setTimeout(() => setMsg(null), 3000)
+ // errors and warnings stay until dismissed or superseded; plain success fades
+ if (type === 'ok') setTimeout(() => setMsg(null), 3000)
}
async function openLog() {
@@ -530,26 +833,77 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}
}
- async function saveNote(logId, text) {
+ // note and tag are annotations on history — saving one never touches forecast rows
+ async function saveLogField(logId, field, text) {
+ const value = text.trim()
try {
- const res = await fetch(`/api/log/${logId}`, {
- method: 'PATCH', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ note: text })
+ const res = await fetch(`/api/log/${logId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ [field]: value }),
})
- if (!res.ok) { flash('Failed to save note', 'error'); return }
- setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
- setEditingNote(null)
+ if (!res.ok) {
+ const { error } = await res.json().catch(() => ({}))
+ flash(error || `Failed to save ${field}`, 'error')
+ return
+ }
+ const saved = await res.json()
+ setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, [field]: saved[field] ?? null } : e))
+ setEditingCell(null)
+ if (field === 'tag') {
+ // the ledger names its lines from this, and the tag list gains a new entry
+ refreshLogMeta(versionId)
+ refreshTags(sourceId)
+ }
} catch (err) {
flash(err.message, 'error')
}
}
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
- const hasSlice = Object.keys(slice).length > 0
+ const hasSlice = slices.length > 0
+
+ // how many of the selected cells actually resolve to distinct, operable slices
+ const distinctSlices = (() => {
+ const seen = new Set()
+ for (const sl of slices) {
+ const eff = buildEffectiveSlice(sl)
+ seen.add(JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]])))
+ }
+ return seen.size
+ })()
+
+ const panelProps = {
+ distinctSlices,
+ dock,
+ slices, setSlices,
+ applyMode, setApplyMode,
+ currentTotals,
+ activeOp, setActiveOp,
+ scaleInputs, setScaleInputs,
+ targetBasis, setTargetBasis,
+ opTag, setOpTag, knownTags, logMeta,
+ scaleNote, setScaleNote,
+ recodeSet, setRecodeSet,
+ recodeNote, setRecodeNote,
+ cloneSet, setCloneSet,
+ cloneScale, setCloneScale,
+ cloneNote, setCloneNote,
+ dimCols, lookupDerivedCols,
+ buildPayload, submitOp,
+ }
return (
+ {/* Tag completions, shared by the operation panel and the change log's
+ inline editor. Lives here so it is in the DOM even when the panel is shut. */}
+
+ {knownTags.map(t => (
+ {t.uses} use{t.uses === 1 ? '' : 's'}
+ ))}
+
+
{/* Toolbar */}
@@ -618,12 +972,26 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
Change log
+ setShowBridge(true)} disabled={!versionId}
+ title="How this version got from baseline to current, by initiative"
+ className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
+ Bridge
+
+ setPanelOpen(o => !o)} disabled={!versionId}
+ title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
+ className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
+ panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
+ {panelOpen ? 'Hide panel' : 'Operations'}
+ {!panelOpen && hasSlice && (
+ ({slices.length})
+ )}
+
{msg && (
-
+
{msg.text}
- {msg.type === 'error' && (
+ {msg.type !== 'ok' && (
setMsg(null)} className="opacity-60 hover:opacity-100 leading-none">×
)}
@@ -652,6 +1020,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
Time
Op
Slice
+
Tag
Note
Rows
@@ -667,27 +1036,16 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{fmtSlice(entry.slice)}
-
- {editingNote?.id === entry.id ? (
-
- setEditingNote(n => ({ ...n, text: e.target.value }))}
- onKeyDown={e => {
- if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
- if (e.key === 'Escape') setEditingNote(null)
- }}
- className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
- saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800">✓
- setEditingNote(null)} className="text-gray-400 hover:text-gray-600">✕
-
- ) : (
- setEditingNote({ id: entry.id, text: entry.note || '' })}
- className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
- title={entry.note || 'Click to add note'}>
- {entry.note || add note }
+ (
+
+ {v}
- )}
-
+ )} />
+
{entry.row_count ?? '—'}
)}
- {/* Main area */}
-
+
setShowBridge(false)}
+ tableRef={tableRef}
+ viewerRef={viewerRef}
+ logMeta={logMeta}
+ valueCol={colMetaRef.current.find(c => c.role === 'value')?.cname}
+ unitsCol={colMetaRef.current.find(c => c.role === 'units')?.cname}
+ colMeta={colMetaRef.current}
+ slices={slices}
+ excludeIters={versions.find(v => String(v.id) === String(versionId))?.exclude_iters || ['reference']}
+ versionName={versions.find(v => String(v.id) === String(versionId))?.name}
+ />
+
+ {/* Main area — the panel lives in one of three shells, chosen by `dock` */}
+
+
{/* Perspective viewer */}
-
+
{loading && (
Loading…
@@ -740,170 +1113,145 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
- {/* Drag handle */}
-
-
- {/* Operation panel */}
-
-
-
Slice
- {!hasSlice ? (
-
Click a pivot row to select a slice
- ) : (
-
- {Object.entries(slice).map(([k, v]) => (
-
- {k} = {v}
-
- ))}
-
setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear
-
- )}
-
-
- {hasSlice && currentTotals?.byIter?.length > 0 && (
-
-
Current
-
-
-
-
- {currentTotals.valueCol && {currentTotals.valueCol} }
- {currentTotals.unitsCol && {currentTotals.unitsCol} }
- {currentTotals.valueCol && currentTotals.unitsCol && price }
-
-
-
- {currentTotals.byIter.map(r => (
-
- {r.iter}
- {currentTotals.valueCol && {fmtNum(r.value)} }
- {currentTotals.unitsCol && {fmtNum(r.units)} }
- {currentTotals.valueCol && currentTotals.unitsCol && {fmtNum(r.units ? r.value / r.units : null, 4)} }
-
- ))}
- {currentTotals.byIter.length > 1 && (
-
- total
- {currentTotals.valueCol && {fmtNum(currentTotals.total.value)} }
- {currentTotals.unitsCol && {fmtNum(currentTotals.total.units)} }
- {currentTotals.valueCol && currentTotals.unitsCol && {fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)} }
-
- )}
-
-
+ {/* Docked panel: bottom strip or right rail, with a resize handle */}
+ {dock !== 'float' && panelOpen && (
+ <>
+
+
+
setPanelOpen(false)} />
+
- )}
-
- {hasSlice && (
- <>
-
- {['scale', 'recode', 'clone'].map(op => (
- setActiveOp(op)}
- className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
- {op}
-
- ))}
-
-
-
- {activeOp === 'scale' && <>
- {/* Mode toggle */}
-
- {[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
- { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
- className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
- {label}
-
- ))}
-
-
- {currentTotals?.valueCol && (
-
- setScaleValue(e.target.value)}
- placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
- className={inp} />
-
- )}
-
- {currentTotals?.unitsCol && (
-
- setScaleUnits(e.target.value)}
- placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
- className={inp} />
-
- )}
-
- {scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
-
- setScalePrice(e.target.value)}
- placeholder="target price (holds units)"
- className={inp} />
-
- )}
-
- {scaleMode === 'delta' && (
-
- setScalePct(e.target.checked)} /> % of slice
-
- )}
-
-
setScaleNote(e.target.value)} placeholder="optional" className={inp} />|
-
-
submitOp('scale')}>Apply Scale
- >}
-
- {activeOp === 'recode' && <>
-
New values for dimensions to replace. Leave blank to keep.
- {dimCols.map(c => (
-
- setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
- onBlur={c.is_key && c.dim_group
- ? e => lookupDerivedCols(c.cname, e.target.value, setRecodeSet)
- : undefined}
- placeholder={slice[c.cname] || '—'}
- className={`${inp} font-mono`} />
-
- ))}
-
setRecodeNote(e.target.value)} placeholder="optional" className={inp} />|
-
-
submitOp('recode')}>Apply Recode
- >}
-
- {activeOp === 'clone' && <>
-
Override dimensions on cloned rows. Leave blank to keep.
- {dimCols.map(c => (
-
- setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
- onBlur={c.is_key && c.dim_group
- ? e => lookupDerivedCols(c.cname, e.target.value, setCloneSet)
- : undefined}
- placeholder={slice[c.cname] || '—'}
- className={`${inp} font-mono`} />
-
- ))}
-
setCloneScale(e.target.value)} className={inp} />|
-
setCloneNote(e.target.value)} placeholder="optional" className={inp} />|
-
-
submitOp('clone')}>Apply Clone
- >}
-
- >
- )}
-
+ >
+ )}
+
+ {/* Floating panel: the pivot keeps the full window and this floats over it,
+ draggable by its header and resizable from the bottom-right grip */}
+ {dock === 'float' && panelOpen && floatRect && (
+
+
setPanelOpen(false)} />
+
+
+
+
+
+ )}
+
)
}
-const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
+// Dock switcher — the panel goes where you want it, and remembers.
+function PanelChrome({ dock, setDock, floating, onMouseDown, onClose }) {
+ const opts = [
+ ['bottom', 'Dock to bottom',
],
+ ['right', 'Dock to right',
],
+ ['float', 'Float over grid',
],
+ ]
+ return (
+
+
+ Operations{floating ? ' — drag to move' : ''}
+
+ {opts.map(([val, title, icon]) => (
+ setDock(val)} title={title}
+ className={`w-6 h-6 flex items-center justify-center rounded ${dock === val ? 'bg-blue-50 text-blue-600' : 'text-gray-300 hover:text-gray-500 hover:bg-gray-100'}`}>
+ {icon}
+
+ ))}
+
+
+ ×
+
+
+ )
+}
+
+// One inline-editable annotation cell in the change log. Click to edit, Enter to
+// save, Escape to cancel — the same gesture for note and tag.
+function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listId, render }) {
+ const active = editing?.id === entry.id && editing?.field === field
+ const value = entry[field] || ''
+
+ if (active) {
+ return (
+
+
+ setEditing(c => ({ ...c, text: e.target.value }))}
+ onKeyDown={e => {
+ if (e.key === 'Enter') onSave(entry.id, field, editing.text)
+ if (e.key === 'Escape') setEditing(null)
+ }}
+ className="border border-blue-400 rounded px-1.5 py-0.5 text-xs flex-1 min-w-0 focus:outline-none" />
+ onSave(entry.id, field, editing.text)}
+ className="text-blue-600 hover:text-blue-800">✓
+ setEditing(null)}
+ className="text-gray-500 hover:text-gray-700">✕
+
+
+ )
+ }
+
+ return (
+
+ setEditing({ id: entry.id, field, text: value })}
+ className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
+ title={value || `Click to ${placeholder}`}>
+ {value
+ ? (render ? render(value) : value)
+ : {placeholder} }
+
+
+ )
+}
+
+// Perspective encodes a clicked/selected row position as [col, '==', value] triples
+function sliceFromFilters(filters) {
+ const s = {}
+ for (const f of filters) {
+ if (!Array.isArray(f)) continue
+ const [col, op, val] = f
+ if (op === '==' && val != null) s[col] = String(val)
+ }
+ return s
+}
+
+// slices are compared by value — the same row clicked twice is the same slice
+function sliceKey(s) {
+ return JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]))
+}
+function addSlice(list, s) {
+ return list.some(x => sliceKey(x) === sliceKey(s)) ? list : [...list, s]
+}
+function removeSlice(list, s) {
+ return list.filter(x => sliceKey(x) !== sliceKey(s))
+}
+function toggleSlice(list, s) {
+ return list.some(x => sliceKey(x) === sliceKey(s)) ? removeSlice(list, s) : [...list, s]
+}
function fmtBytes(n) {
if (n < 1024) return `${n} B`
@@ -911,11 +1259,6 @@ function fmtBytes(n) {
return `${(n / 1048576).toFixed(1)} MB`
}
-function fmtNum(n, decimals = 2) {
- if (n == null || !isFinite(n)) return '—'
- return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
-}
-
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
@@ -934,28 +1277,3 @@ const OP_BADGE = {
}
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }
-function Row({ label, children }) {
- return (
-
- {label}
- {children}
-
- )
-}
-
-function Submit({ onClick, children }) {
- return (
-
- {children}
-
- )
-}
-
-function PayloadPreview({ payload }) {
- if (!payload) return null
- return (
-
- {JSON.stringify(payload, null, 2)}
-
- )
-}