From 55814ee0d54b0122ab157d4cc978e249e31ccba7 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 11 Sep 2026 23:30:56 -0400 Subject: [PATCH] Multi-slice operations, tagged bridge, and a reworked adjustment panel Forecast operations could only act on one clicked row at a time, and the panel that drove them separated the numbers you were reading from the inputs that changed them. This reworks both, and adds initiative tags so a version's history can be read as a bridge. Operations - Accept `slices` (array) alongside the legacy single `slice`, with apply_mode 'prorate' (one pool) or 'each' (independent per slice). - buildWhereAny() ORs the slices into one predicate. A union of slices cannot be flattened into per-column IN lists without over-selecting, and the result is parenthesised so the appended exclude clause does not bind wrong. - resolveIncrs() now resolves each measure independently: target, percent or change amount per measure, so a target on value and a percent on units can be submitted together. Replaces the single global `mode`. - target_basis chooses what a target measures against: only the rows an operation can write, or everything the pivot shows for the slice. Excluded iters are visible in the grid but immovable, so a target set against the visible total previously overshot by their contribution. Two latent bugs surfaced by the above, both pre-existing: - A slice naming no filterable column reduced to TRUE and applied the operation to the entire version. Now rejected on all three operations. - Prorating across a pool that nets to ~zero multiplies each row's share by an exploding factor, sending rows to extreme opposite values to hit the target. Refused when the net falls below 1% of gross. Tags and the bridge - pf.log gains a nullable `tag`, written by a follow-up UPDATE rather than through the generated SQL: those templates are stored per source in pf.sql, so a {{tag}} token would strand any source that had not re-run "Generate SQL". - Tag is editable after the fact in the change log, with completion from tags already used on the source. PATCH branches on whether a field was sent, so a tag can be cleared as well as set. - BridgeView renders the walk from baseline to current as a waterfall, one step per tag, scoped to the selection, the pivot's filters, or the whole version. Computed from the loaded Perspective table so the figures always reconcile with what is on screen; overlapping slices are deduped by pf_id to match the OR semantics operations use. - Colour is a polarity job, so it uses the validated diverging pair (blue/red, CVD dE 21.6) with neutral anchors, not categorical hues. Every bar is directly labelled and a table view is available. Panel - Extracted to OperationPanel; the scale form is one continuous ledger: baseline, each adjustment, current, then New value / Change / % change as three interchangeable editable rows. Typing in any one derives the others, which removes the target/delta/percent mode toggle entirely. - Dockable bottom, right, or floating (drag to move, grip to resize), and closable via header, Esc, or the toolbar. Placement persists. - Controls no longer stretch to the dock width, and text contrast now clears WCAG AA against white throughout. Also: the status bar names the physical table writes land in, with live row counts; and the pivot's expand depth is re-applied when the tab regains focus, since Perspective rebuilds its view on redraw and a ROLLUP view with no depth set renders fully expanded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M --- lib/sql_generator.js | 31 +- routes/log.js | 21 +- routes/operations.js | 412 +++++++++--- routes/versions.js | 119 ++++ setup_sql/01_schema.sql | 8 +- ui/src/components/BridgeView.jsx | 402 ++++++++++++ ui/src/components/OperationPanel.jsx | 683 +++++++++++++++++++ ui/src/components/StatusBar.jsx | 86 ++- ui/src/views/Forecast.jsx | 938 ++++++++++++++++++--------- 9 files changed, 2298 insertions(+), 402 deletions(-) create mode 100644 ui/src/components/BridgeView.jsx create mode 100644 ui/src/components/OperationPanel.jsx 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)}
-    
- ) -}