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