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]) => ( + + ))} +
+
+ + + + {/* 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 && ( + + + + + + {unitsCol && } + + + + + + + {steps.map(s => ( + + + + {unitsCol && ( + + )} + + + + + ))} + +
Step{valueCol}{unitsCol}RunningAdjustmentsRows
+ {s.label}{!s.tagged && s.kind === 'step' && · untagged} + + {s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)} + + {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 ( + + ) +} + +function Segmented({ options, value, onChange }) { + return ( +
+ {options.map(([val, label, title]) => ( + + ))} +
+ ) +} + +function Submit({ onClick, children, disabled }) { + return ( + + ) +} + +// ── 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 && valueCol && ( + + )} + + + ))} + +
+ {multi + ? {sliceLabel(s)} + : ( +
+ {Object.entries(s).map(([k, v]) => ( +
+ {k} + = + {v} +
+ ))} +
+ )} +
+ {fmtNum(perSlice[i]?.total?.value)} + + +
+
+ +
+ ) +} + +// 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 ( +
+ + {open && ( + + + {rows.map(r => ( + + + {valueCol && } + {unitsCol && } + + ))} + +
{r.iter}{fmtNum(r.value)}{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 => ( + + ))} + + + + {/* the walk from baseline to current, by initiative */} + {lines.map(e => ( + + + {measures.map(m => ( + + ))} + + ))} + + {rule}{measures.map(m => )} + + + + {measures.map(m => ( + + ))} + + + {/* Rows the pivot shows but operations cannot write. Listed so the + panel's figures reconcile with what the grid displays. */} + {hasExcl && ( + + + {measures.map(m => ( + + ))} + + )} + + {hasExcl && ( + + + {measures.map(m => ( + + ))} + + )} + + {rule}{measures.map(m => )} + + {/* the edit — three equivalent ways to say the same thing */} + {FIELDS.map(([field, label]) => ( + + + {measures.map(m => { + const active = scaleInputs[m.key]?.field === field + return ( + + ) + })} + + ))} + +
+ {m.label} + {m.hint && · {m.hint}} +
+ {e.kind === 'tag' && } + {e.label} + {e.kind === 'tag' && e.count > 1 && ×{e.count}} + + {m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)} +
+ {hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''} + {fmtNum(m.current, m.dp)}
+ {exclName} · fixed + + {m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)} +
Selected total + {m.key === 'price' + ? fmtNum(grand.units ? grand.value / grand.units : null, m.dp) + : fmtNum(grand[m.key], m.dp)} +
+ {label}{perSlice && field === 'new' ? ' (each)' : ''} + + 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 ( +
+ + + + + + + + + + {dimCols.map(c => { + const cur = multi + ? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—')) + : (first[c.cname] ?? '—') + return ( + + + + + + ) + })} + +
dimensioncurrentnew value
{c.label || c.cname}{cur} + setSet(s => ({ ...s, [c.cname]: e.target.value }))} + onBlur={c.is_key && c.dim_group + ? e => lookupDerivedCols(c.cname, e.target.value, setSet) + : undefined} + placeholder="keep" + className={TEXT} /> +
+ {extra} +
+ ) +} + +// 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 ( +
+ + {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 => ( + + ))} +
+ {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() && ( + + )} +
+ {knownTags.length > 0 && ( +
+ {knownTags.slice(0, 6).map(t => ( + + ))} +
+ )} + +
+ 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 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" /> - - -
- ) : ( - 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 ?? '—'} -
- )} -
- - {hasSlice && currentTotals?.byIter?.length > 0 && ( -
-
Current
- - - - - {currentTotals.valueCol && } - {currentTotals.unitsCol && } - {currentTotals.valueCol && currentTotals.unitsCol && } - - - - {currentTotals.byIter.map(r => ( - - - {currentTotals.valueCol && } - {currentTotals.unitsCol && } - {currentTotals.valueCol && currentTotals.unitsCol && } - - ))} - {currentTotals.byIter.length > 1 && ( - - - {currentTotals.valueCol && } - {currentTotals.unitsCol && } - {currentTotals.valueCol && currentTotals.unitsCol && } - - )} - -
{currentTotals.valueCol}{currentTotals.unitsCol}price
{r.iter}{fmtNum(r.value)}{fmtNum(r.units)}{fmtNum(r.units ? r.value / r.units : null, 4)}
total{fmtNum(currentTotals.total.value)}{fmtNum(currentTotals.total.units)}{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 => ( - - ))} -
- -
- {activeOp === 'scale' && <> - {/* Mode toggle */} -
- {[['target','= Target'],['delta','Δ Increment']].map(([m, 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' && ( - - )} - - 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]) => ( + + ))} + + +
+ ) +} + +// 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" /> + + +
+ + ) + } + + 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 ( - - ) -} - -function PayloadPreview({ payload }) { - if (!payload) return null - return ( -
-      {JSON.stringify(payload, null, 2)}
-    
- ) -}