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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
This commit is contained in:
Paul Trowbridge 2026-09-11 23:30:56 -04:00
parent 99375bb534
commit 55814ee0d5
9 changed files with 2298 additions and 402 deletions

View File

@ -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 };

View File

@ -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]);

View File

@ -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 = [];
if (absValueIncr === 0 && absUnitsIncr === 0) {
return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' });
}
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({ slice, value_incr, units_incr, pct })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
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: absValueIncr,
units_incr: absUnitsIncr
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);
}
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 });
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();
}
} 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' });
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 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({ slice, set })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
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);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length });
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' });
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 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({ slice, set, scale: scaleFactor })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
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);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length });
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 });

View File

@ -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;

View File

@ -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,

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-gray-700 text-sm">Bridge</span>
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
<span className="text-gray-600 text-xs">
· {scope === 'selection'
? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
: scope === 'filtered' ? "pivot's filters" : 'whole version'}
</span>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
</div>
{/* controls — one row above the chart */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-100 shrink-0 text-xs flex-wrap">
<span className="text-gray-600">Scope</span>
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
{[
['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]) => (
<button key={v} onClick={() => setScope(v)} title={title}
disabled={v === 'selection' && !hasSelection}
className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${
scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
{l}
</button>
))}
</div>
<div className="w-px h-4 bg-gray-200" />
<button onClick={() => setAsTable(t => !t)}
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
{asTable ? 'Show chart' : 'Show table'}
</button>
<button onClick={compute} disabled={loading}
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40">
{loading ? 'Computing…' : 'Refresh'}
</button>
{/* legend — identity is never colour alone, but say it anyway */}
<div className="ml-auto flex items-center gap-3 text-gray-700">
{[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => (
<span key={l} className="inline-flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: c }} />
{l}
</span>
))}
</div>
</div>
<div className="overflow-auto p-5" ref={boxRef}>
{error && <p className="text-red-600">{error}</p>}
{!error && !steps && <p className="text-gray-600">Computing</p>}
{!error && steps && steps.length <= 2 && (
<p className="text-gray-600">
No adjustments in scope the bridge shows the walk from baseline to current,
and this selection has only a baseline.
</p>
)}
{!error && steps && steps.length > 2 && !asTable && (
<div className="relative">
<svg width={width} height={H} role="img"
aria-label={`Bridge from baseline ${fmt(steps[0].end)} to current ${fmt(steps[steps.length - 1].end)}`}>
{/* recessive grid */}
{ticks.map(t => (
<g key={t}>
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
{fmtAxis(t)}
</text>
</g>
))}
{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 (
<g key={s.key}
onMouseEnter={() => setHover({ ...s, x: x + barW / 2, y: top })}
onMouseLeave={() => setHover(null)}>
{/* connector to the next bar, drawn behind */}
{i < steps.length - 1 && (
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
)}
{/* hit target larger than the mark */}
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
opacity={on ? 1 : 0.92}
stroke="#ffffff" strokeWidth="2" />
{/* direct label: few bars, so every one is labelled */}
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
fill={INK} fontWeight="500">
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
</text>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
{s.label.length > 12 ? `${s.label.slice(0, 11)}` : s.label}
</text>
{!isAnchor && s.entries > 1 && (
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
×{s.entries}
</text>
)}
{!s.tagged && !isAnchor && (
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
untagged
</text>
)}
</g>
)
})}
</svg>
{hover && (
<div className="absolute pointer-events-none bg-white border border-gray-300 rounded shadow-lg px-2.5 py-1.5 text-xs"
style={{ left: Math.min(hover.x + 10, width - 190), top: Math.max(0, hover.y - 10) }}>
<div className="font-medium text-gray-800">{hover.label}</div>
<div className="text-gray-700 font-mono tabular-nums">
{hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)}
</div>
{hover.kind === 'step' && (
<div className="text-gray-600">
running <span className="font-mono tabular-nums">{fmt(hover.end)}</span>
</div>
)}
<div className="text-gray-600">
{hover.rows} row{hover.rows === 1 ? '' : 's'}
{hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''}
</div>
</div>
)}
</div>
)}
{/* table view — the same numbers, at full precision */}
{!error && steps && steps.length > 2 && asTable && (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-600 border-b border-gray-200">
<th className="text-left py-1.5 pr-3 font-medium">Step</th>
<th className="text-right py-1.5 px-2 font-medium">{valueCol}</th>
{unitsCol && <th className="text-right py-1.5 px-2 font-medium">{unitsCol}</th>}
<th className="text-right py-1.5 px-2 font-medium">Running</th>
<th className="text-right py-1.5 px-2 font-medium">Adjustments</th>
<th className="text-right py-1.5 pl-2 font-medium">Rows</th>
</tr>
</thead>
<tbody>
{steps.map(s => (
<tr key={s.key} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-800">
{s.label}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
</td>
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
</td>
{unitsCol && (
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">
{s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)}
</td>
)}
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">{fmt(s.end)}</td>
<td className="py-1.5 px-2 text-right text-gray-700">{s.kind === 'step' ? s.entries : '—'}</td>
<td className="py-1.5 pl-2 text-right text-gray-700">{s.rows}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}

View File

@ -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 (
<section className={[
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
grow ? 'flex-1 min-w-0' : 'shrink-0',
].join(' ')}>
{title && (
<header className="flex items-baseline gap-1.5 mb-2">
<h3 className="font-semibold text-gray-600 uppercase tracking-wide" style={{ fontSize: '10px' }}>{title}</h3>
{hint && <span className="text-gray-600" style={{ fontSize: '10px' }}>{hint}</span>}
</header>
)}
{children}
</section>
)
}
function Button({ onClick, active, children, title }) {
return (
<button onClick={onClick} title={title}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
{children}
</button>
)
}
function Segmented({ options, value, onChange }) {
return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
))}
</div>
)
}
function Submit({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap">
{children}
</button>
)
}
// 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 (
<p className="text-gray-600 italic leading-relaxed">
Click a pivot row to select a slice.<br />
<span className="text-gray-500">Ctrl/-click to add more.</span>
</p>
)
}
return (
<div className="min-w-0">
<div className="overflow-auto max-h-36 -mx-1 px-1">
<table className="w-full">
<tbody>
{slices.map((s, i) => (
<tr key={i} className="align-top hover:bg-gray-50">
<td className="py-0.5 pr-2">
{multi
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
: (
<div className="flex flex-col gap-0.5">
{Object.entries(s).map(([k, v]) => (
<div key={k} className="whitespace-nowrap">
<span className="text-gray-600">{k}</span>
<span className="text-gray-500"> = </span>
<span className="font-medium text-gray-700 font-mono">{v}</span>
</div>
))}
</div>
)}
</td>
{multi && valueCol && (
<td className="py-0.5 pl-2 text-right font-mono tabular-nums text-gray-600 whitespace-nowrap">
{fmtNum(perSlice[i]?.total?.value)}
</td>
)}
<td className="py-0.5 pl-1 text-right">
<button onClick={() => onRemove(i)} title="Remove from selection"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={onClear} className="text-gray-600 hover:text-red-500 mt-1.5">Clear selection</button>
</div>
)
}
// 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 (
<div className="mt-2">
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} breakdown by iter
</button>
{open && (
<table className="mt-1 text-gray-500">
<tbody>
{rows.map(r => (
<tr key={r.iter}>
<td className="capitalize pr-3">{r.iter}</td>
{valueCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.value)}</td>}
{unitsCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.units)}</td>}
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
// 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 (
<span className="inline-flex items-center gap-1">
<input
type="text" inputMode="decimal" value={value} onChange={e => 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 && <span className="text-gray-500" style={{ fontSize: '10px' }}>{suffix}</span>}
</span>
)
}
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 = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
return (
<div className="min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3"></th>
{measures.map(m => (
<th key={m.key} className="text-right font-normal pb-1 px-2 whitespace-nowrap">
{m.label}
{m.hint && <span className="text-gray-500"> · {m.hint}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{/* the walk from baseline to current, by initiative */}
{lines.map(e => (
<tr key={e.key} className="text-gray-600">
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
{e.kind === 'tag' && <span className="text-blue-600"> </span>}
{e.label}
{e.kind === 'tag' && e.count > 1 && <span className="text-gray-500"> ×{e.count}</span>}
</td>
{measures.map(m => (
<td key={m.key} className={`${numCell} text-gray-600`}>
{m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)}
</td>
))}
</tr>
))}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
<td className="pr-3 whitespace-nowrap">
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
))}
</tr>
{/* Rows the pivot shows but operations cannot write. Listed so the
panel's figures reconcile with what the grid displays. */}
{hasExcl && (
<tr className="text-gray-600">
<td className="pr-3 whitespace-nowrap">
{exclName} <span className="text-gray-500">· fixed</span>
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)}
</td>
))}
</tr>
)}
{hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
<td className="pr-3 whitespace-nowrap">Selected total</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price'
? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
: fmtNum(grand[m.key], m.dp)}
</td>
))}
</tr>
)}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
{/* the edit — three equivalent ways to say the same thing */}
{FIELDS.map(([field, label]) => (
<tr key={field}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap">
{label}{perSlice && field === 'new' ? ' (each)' : ''}
</td>
{measures.map(m => {
const active = scaleInputs[m.key]?.field === field
return (
<td key={m.key} className="px-2 py-0.5 text-right">
<LedgerInput
value={derived[m.key]?.[field] ?? ''}
active={active}
onChange={(raw) => setEdit(m.key, field, raw)}
onFocus={() => focusRow(m.key, field)}
suffix={field === 'pct' ? '%' : null}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
{hasExcl && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
<Segmented
value={onTotal ? 'selected' : 'adjustable'}
onChange={setTargetBasis}
options={[
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
]}
/>
</div>
<p className="text-gray-600 leading-snug max-w-md">
{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)}.`}
</p>
</div>
)}
{perSlice && (
<p className="text-gray-600 leading-snug mt-2 max-w-md">
Applied to each slice separately the figures above are combined totals,
so each slice's own change will differ.
</p>
)}
</div>
)
}
// 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 (
<div className="flex flex-col gap-2.5 min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3">dimension</th>
<th className="text-left font-normal pb-1 px-2">current</th>
<th className="text-left font-normal pb-1 pl-2">new value</th>
</tr>
</thead>
<tbody>
{dimCols.map(c => {
const cur = multi
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
: (first[c.cname] ?? '—')
return (
<tr key={c.cname}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
<td className="pl-2 py-0.5">
<input
value={setObj[c.cname] || ''}
onChange={e => 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} />
</td>
</tr>
)
})}
</tbody>
</table>
{extra}
</div>
)
}
// 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 (
<p className="text-gray-500">
{verb}{' '}
{valueCol && <span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value)}</span>}
{valueCol && <span className="text-gray-600"> {valueCol}</span>}
{unitsCol && <>
<span className="text-gray-500"> · </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.units)}</span>
<span className="text-gray-600"> {unitsCol}</span>
</>}
{scaled && valueCol && <>
<span className="text-gray-500"> </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value * factor)}</span>
</>}
</p>
)
}
// 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 (
<div className="flex flex-col gap-1.5">
<Segmented value={applyMode} onChange={setApplyMode}
options={options.map(([v, l, t]) => [v, l, t])} />
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
</div>
)
}
function RequestPreview({ payload }) {
const [open, setOpen] = useState(false)
if (!payload) return null
return (
<div>
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} request
</button>
{open && (
<pre className="mt-1 font-mono text-gray-600 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-40 leading-relaxed">
{JSON.stringify(payload, null, 2)}
</pre>
)}
</div>
)
}
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 (
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
<Block title="Slice" hint={hasSlice ? `${slices.length} selected` : null}
horizontal={horizontal} grow>
{/* 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 && (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
{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.
</p>
)}
<SelectionList
slices={slices}
currentTotals={currentTotals}
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
onClear={() => setSlices([])}
/>
</Block>
{hasSlice && (
<Block horizontal={horizontal} grow>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 flex-wrap">
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
</Button>
))}
</div>
{multi && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
</div>
{activeOp === 'scale' && (
<ScaleLedger
currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
multi={multi} applyMode={applyMode}
/>
)}
{activeOp === 'recode' && (
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
slices={slices} lookupDerivedCols={lookupDerivedCols}
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
)}
{activeOp === 'clone' && (
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
slices={slices} lookupDerivedCols={lookupDerivedCols}
extra={
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-gray-500">scale cloned rows by</span>
<input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} className={INPUT} />
</div>
<MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1} />
</div>
} />
)}
</div>
</Block>
)}
{hasSlice && (
<Block horizontal={horizontal}>
<div className="flex flex-col gap-2.5 min-w-0">
{/* 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. */}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
<input
value={opTag} onChange={e => setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
className={`${TEXT} w-48`} />
{opTag.trim() && (
<button onClick={() => setOpTag('')} title="Clear tag"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
)}
</div>
{knownTags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{knownTags.slice(0, 6).map(t => (
<button key={t.tag} onClick={() => setOpTag(t.tag)}
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
opTag.trim() === t.tag
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
{t.tag}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">note</span>
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} />
</div>
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
<RequestPreview payload={buildPayload(activeOp)} />
</div>
</Block>
)}
</div>
)
}

View File

@ -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 (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
<span className="text-gray-400">Source</span>
<select
value={sourceId || ''}
@ -38,6 +71,57 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
{selectedVersion.status}
</span>
)}
{/* write target — the physical table every operation appends to */}
{info && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400" title="Operations append to this table">writes to</span>
<button
onClick={copyTable}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
}`}
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
>
{copied ? 'copied!' : info.fc_table}
</button>
<span className="text-gray-400 font-mono">
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
</span>
{showInfo && (
<div className="absolute top-9 left-0 z-30 bg-white border border-gray-200 rounded shadow-lg p-3 text-xs min-w-[260px]">
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<span className="text-gray-400">table</span>
<span className="font-mono text-gray-700">{info.fc_table}</span>
<span className="text-gray-400">reads from</span>
<span className="font-mono text-gray-700">{info.source}</span>
<span className="text-gray-400">total rows</span>
<span className="font-mono text-gray-700">{fmt(info.rows)}</span>
</div>
{info.by_iter?.length > 0 && (
<>
<div className="text-gray-400 uppercase tracking-wide mt-3 mb-1" style={{ fontSize: '10px' }}>Rows by iter</div>
<table className="w-full">
<tbody>
{info.by_iter.map(r => (
<tr key={r.pf_iter}>
<td className="text-gray-500 capitalize pr-3">{r.pf_iter}</td>
<td className="text-right font-mono text-gray-700">{fmt(r.n)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</>
)}
</>
)}

File diff suppressed because it is too large Load Diff