From 916229bdab9998d492fab076c8f3ef8d33dfd664 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 13:33:12 -0400 Subject: [PATCH] Sequence the bucket and segment columns with expression columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perspective orders column groups by the value string, and SortDir's col asc / col desc only reverses that -- so Prior Year -> Plan -> Actual -> Forecast is expressible as neither, being alphabetical in neither direction. The order has to be part of the value, as a "01 · " prefix. Done as Perspective expression columns, named Bucket and Segment, generated from pf.version.bucket_order and pf.log.seq. The first attempt computed the prefix in the served SQL (archived on feature/column-sequencing-sql), which was the wrong layer: the prefix is a pivot-ordering concern, and putting it in the query put it in every other reader too -- the change log and the bridge's basis list would both have read "04 · Forecast". It also meant a Generate SQL to introduce the placeholder, and a reload to see any change. As expressions it stays in the pivot, travels with saved layouts because it lives in ViewConfig, and reordering on the Baseline page takes effect immediately -- the expressions are rebuilt and the pivot re-renders, no reload. Kept from the SQL attempt: pf.version.bucket_order and pf.log.seq, which are needed either way, and the Baseline controls -- a seq column per segment and a reorderable row of bucket chips. bucket_order is on the version because the Baseline page is version-scoped; Setup is the only source-level context and not where anyone would look for this. Anything unordered falls through to the raw column, so it sorts after the ordered entries (digits before letters) rather than silently landing first. The expressions are merged into the live config rather than replacing it, so a user's own expressions survive, and an expression that stops existing is dropped from the axes first -- restore() rejects a config that pivots on an expression it no longer defines. Needs 01_schema.sql for the two columns. No Generate SQL. Co-Authored-By: Claude Opus 5 (1M context) --- routes/log.js | 11 ++-- routes/versions.js | 12 +++-- setup_sql/01_schema.sql | 14 +++++ ui/src/views/Baseline.jsx | 97 +++++++++++++++++++++++++++++++-- ui/src/views/Forecast.jsx | 111 +++++++++++++++++++++++++++++++++++++- 5 files changed, 234 insertions(+), 11 deletions(-) diff --git a/routes/log.js b/routes/log.js index 8d2d703..130dd41 100644 --- a/routes/log.js +++ b/routes/log.js @@ -131,9 +131,9 @@ module.exports = function(pool) { // a closed version, where relabelling history is still legitimate. router.patch('/log/:logid', async (req, res) => { const logId = parseInt(req.params.logid); - const { note, tag, bucket } = req.body; - if (note === undefined && tag === undefined && bucket === undefined) { - return res.status(400).json({ error: 'Nothing to update — send note, tag and/or bucket' }); + const { note, tag, bucket, seq } = req.body; + if (note === undefined && tag === undefined && bucket === undefined && seq === undefined) { + return res.status(400).json({ error: 'Nothing to update — send note, tag, bucket and/or seq' }); } try { // COALESCE on the flag, not the value: an explicit null or '' must be @@ -142,13 +142,16 @@ module.exports = function(pool) { `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, - bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END + bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END, + seq = CASE WHEN $8::bool THEN $9::int ELSE seq END WHERE id = $1 RETURNING *`, [ logId, note !== undefined, note === undefined ? null : (String(note).trim() || null), tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null), + seq !== undefined, (seq === undefined || seq === null || seq === '') + ? null : parseInt(seq), ] ); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); diff --git a/routes/versions.js b/routes/versions.js index 5c9781f..7087bed 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -309,20 +309,26 @@ ${colDefs}, // update version name, description, or exclude_iters router.put('/versions/:id', async (req, res) => { - const { name, description, exclude_iters } = req.body; + const { name, description, exclude_iters, bucket_order } = req.body; try { + // bucket_order is a flag-and-value pair rather than COALESCE: an empty + // array is a meaningful value (no ordering), and COALESCE could not tell + // it from "not mentioned". const result = await pool.query(` UPDATE pf.version SET name = COALESCE($2, name), description = COALESCE($3, description), - exclude_iters = COALESCE($4, exclude_iters) + exclude_iters = COALESCE($4, exclude_iters), + bucket_order = CASE WHEN $5::bool THEN $6::jsonb ELSE bucket_order END WHERE id = $1 RETURNING * `, [ req.params.id, name || null, description || null, - exclude_iters ? JSON.stringify(exclude_iters) : null + exclude_iters ? JSON.stringify(exclude_iters) : null, + bucket_order !== undefined, + bucket_order === undefined ? null : JSON.stringify(bucket_order || []) ]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Version not found' }); diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index 6315893..a2e7b9c 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -81,6 +81,20 @@ WHERE TRUE AND note <> '' AND operation IN ('baseline', 'reference'); +-- Display order for the pivot's segment and bucket columns. +-- +-- Perspective orders column groups by the value string, and SortDir's col asc / +-- col desc only reverses that -- so no sort setting can produce +-- Prior Year -> Plan -> Actual -> Forecast, which is alphabetical in neither +-- direction. The order has to be carried in the value itself, as a "01 · " style +-- prefix applied when the rows are served. +-- +-- bucket_order lives on the version rather than the source because the Baseline +-- page, where it is maintained, is version-scoped. log.seq orders the segments +-- within that. +ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb; +ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer; + -- What a segment contributes to, independent of pf_iter. -- -- pf_iter answers "can operations write to these rows"; bucket answers "does this diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 08551e8..0c151fc 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -133,6 +133,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio // locally while typing so the field does not fight the fetched value, and // written on blur. const [buckets, setBuckets] = useState({}) + // seq orders the segment columns; bucket_order orders the bucket columns. Both + // are carried into the pivot as a "01 · " prefix, because Perspective orders + // column groups by the value string and no sort setting can express an + // arbitrary order. + const [seqs, setSeqs] = useState({}) + const [bucketOrder, setBucketOrder] = useState([]) async function saveBucket(entry, value) { const next = value.trim() @@ -151,10 +157,48 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio } } + async function saveSeq(entry, value) { + const raw = String(value).trim() + const next = raw === '' ? null : parseInt(raw) + if (raw !== '' && !Number.isFinite(next)) { flash('Sequence must be a number', 'error'); return } + if (next === (entry.seq ?? null)) return + try { + const res = await fetch(`/api/log/${entry.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seq: next }), + }) + if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } + loadLog() + flash('Order saved') + } catch (err) { flash(err.message, 'error') } + } + + async function saveBucketOrder(next) { + setBucketOrder(next) + try { + const res = await fetch(`/api/versions/${versionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket_order: next }), + }) + if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } + flash('Bucket order saved') + } catch (err) { flash(err.message, 'error') } + } + function loadLog() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference')) setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation))) + + // The stored order first, then any bucket actually in use that it does not + // name — so labelling a new segment makes its bucket appear at the end, + // ready to be moved, rather than silently missing from the list. + const inUse = [...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))] + const stored = versions.find(v => String(v.id) === String(versionId))?.bucket_order + const ordered = (Array.isArray(stored) ? stored : []).filter(b => b) + setBucketOrder([...ordered, ...inUse.filter(b => !ordered.includes(b))]) }) } @@ -371,6 +415,42 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio + {/* Bucket column order. Up/down rather than drag: the list is four or + five items that change once a quarter, and a keyboard-reachable + pair of buttons beats a drag target nobody can hit on a laptop + trackpad. */} + {bucketOrder.length > 1 && ( +
+ column order + {bucketOrder.map((b, i) => ( + + {String(i + 1).padStart(2, '0')} + {b} + + + + ))} +
+ )} + + @@ -392,11 +473,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - + )} {!showAddForm && !editingLogId && ( - + {isOpen && ( -
#seq note counts toward rows
No segments loaded yet
No segments loaded yet
+ {isOpen ? '▾' : '▸'} {log.length - i} e.stopPropagation()}> + setSeqs(v => ({ ...v, [entry.id]: e.target.value }))} + onBlur={e => saveSeq(entry, e.target.value)} + placeholder="—" + className="w-10 text-right border border-transparent hover:border-gray-200 + focus:border-blue-400 rounded px-1 py-0.5 text-xs + focus:outline-none bg-transparent tabular-nums" /> + {entry.operation} @@ -451,7 +542,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
+
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index fa1a2ce..b5dcb5b 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -30,6 +30,68 @@ function cleanLayout(cfg, validCols) { return c } +// Ordering for the pf_bucket and pf_segment column groups, as Perspective +// expression columns. +// +// Perspective orders column groups by the value string, and SortDir's `col asc` / +// `col desc` only reverses that — so Prior Year → Plan → Actual → Forecast is +// expressible as neither, being alphabetical in neither direction. The order has +// to be part of the value, as a "01 · " prefix. +// +// Done as expressions rather than in SQL. The prefix is a pivot-ordering concern, +// and computing it server-side put it in every other reader too: the change log +// and the bridge's basis list would both show "04 · Forecast". As expressions it +// stays in the pivot, lives in ViewConfig so it travels with saved layouts, and +// reordering takes effect immediately instead of needing a reload. +// +// Syntax per rust/perspective-client/src/rust/config/expressions.rs: columns in +// double quotes, string literals in single, if/else with braces. The ordered +// label is emitted as a literal rather than built with concat() — fewer moving +// parts, and the mapping is known here anyway. +const ORDER_EXPR_NAMES = { bucket: 'Bucket', segment: 'Segment' } + +function orderedLabel(ord, label) { + return `${String(ord).padStart(2, '0')} · ${label}` +} + +function sqlSafe(v) { + return String(v).replace(/'/g, "''") +} + +// pairs: [[rawValue, ordinal], ...]. Anything unlisted falls through to the raw +// column, so it sorts after the ordered entries (digits before letters) rather +// than silently landing first. +function buildOrderExpression(sourceCol, pairs) { + const cases = pairs + .filter(([label, ord]) => label && ord > 0) + .sort((a, b) => a[1] - b[1]) + .map(([label, ord]) => + `if ("${sourceCol}" == '${sqlSafe(label)}') { '${sqlSafe(orderedLabel(ord, label))}' }`) + if (cases.length === 0) return null + return `${cases.join('\nelse ')}\nelse { "${sourceCol}" }` +} + +// Build both expressions from the version's bucket_order and the log's seq values. +function buildOrderExpressions(bucketOrder, logMeta) { + const out = {} + + const buckets = (Array.isArray(bucketOrder) ? bucketOrder : []) + .map((label, i) => [label, i + 1]) + const bucketExpr = buildOrderExpression('pf_bucket', buckets) + if (bucketExpr) out[ORDER_EXPR_NAMES.bucket] = bucketExpr + + // Segments are ordered by pf.log.seq, keyed on the label the pivot shows — + // which is the tag if there is one, else the note, the same precedence the + // server uses to build pf_segment. + const segs = Object.values(logMeta || {}) + .filter(m => m.seq != null && ['baseline', 'reference'].includes(m.operation)) + .map(m => [(m.tag || m.note || '').trim(), m.seq]) + const segExpr = buildOrderExpression('pf_segment', segs) + if (segExpr) out[ORDER_EXPR_NAMES.segment] = segExpr + + return out +} + export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) { const { dark } = useTheme() const [loading, setLoading] = useState(false) @@ -506,7 +568,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio try { const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json()) const map = {} - for (const e of entries) map[e.id] = { tag: e.tag || null, note: e.note || null, operation: e.operation } + for (const e of entries) map[e.id] = { + tag: e.tag || null, note: e.note || null, operation: e.operation, + bucket: e.bucket || null, seq: e.seq ?? null, + } setLogMeta(map) } catch { setLogMeta({}) } } @@ -550,6 +615,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio }, [sourceId, versionId]) useEffect(() => { refreshLogMeta(versionId) }, [versionId]) + // Reordering on the Baseline page takes effect here without a reload: the + // expressions are rebuilt and the pivot re-renders. + useEffect(() => { if (tableRef.current) syncOrderExpressions() }, [logMeta, versions, versionId]) useEffect(() => { refreshTags(sourceId) }, [sourceId]) // Stream an Arrow IPC endpoint into one buffer, reporting download progress. @@ -660,6 +728,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio await viewer.load(tableRef.current) viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light') if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400) + await syncOrderExpressions() // restore last-used layout or build default // Strip cfg.table — table is already loaded by reference above; a stale name @@ -812,6 +881,46 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } + // Keep the ordering expressions in step with the version's bucket_order and the + // log's seq values. Merged into the live config rather than replacing + // expressions, so anything the user defined themselves survives. + // + // Removing an ordering removes its column: leaving a stale Bucket expression + // behind would keep ordering by an order that no longer exists. + async function syncOrderExpressions() { + const viewer = viewerRef.current + if (!viewer) return + const version = versions.find(v => String(v.id) === String(versionId)) + const wanted = buildOrderExpressions(version?.bucket_order, logMeta) + + const { table: _t, ...cfg } = await viewer.save() + const current = cfg.expressions || {} + const managed = Object.values(ORDER_EXPR_NAMES) + + const next = { ...current } + for (const name of managed) delete next[name] + Object.assign(next, wanted) + + const same = JSON.stringify(next) === JSON.stringify(current) + if (same) return + + // An expression the pivot is using cannot simply vanish; drop it from the + // axes first or restore() rejects the config. + const stillValid = (col) => next[col] !== undefined || !managed.includes(col) + try { + await viewer.restore({ + ...cfg, + expressions: next, + columns: (cfg.columns || []).filter(c => c == null || stillValid(c)), + group_by: (cfg.group_by || []).filter(stillValid), + split_by: (cfg.split_by || []).filter(stillValid), + sort: (cfg.sort || []).filter(([c]) => stillValid(c)), + }) + } catch (err) { + console.error('[syncOrderExpressions]', err) + } + } + // In rollup mode the datagrid emits a grand-total column group as well as the // subtotals. The subtotals are the point -- they are what per-branch collapse // needs -- but the grand total sums across the split, and when the split is