Sequence the bucket and segment columns with 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 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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 13:33:12 -04:00
parent ebe0288202
commit 916229bdab
5 changed files with 234 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@ -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
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
</div>
<table className="w-full text-xs">
{/* 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 && (
<div className="flex items-center gap-2 flex-wrap px-3 py-2 border-b border-gray-100">
<span className="text-gray-500 text-xs whitespace-nowrap">column order</span>
{bucketOrder.map((b, i) => (
<span key={b}
className="inline-flex items-center gap-1 border border-gray-200 rounded
pl-2 pr-1 py-0.5 text-xs bg-white">
<span className="text-gray-400 tabular-nums">{String(i + 1).padStart(2, '0')}</span>
<span className="text-gray-700">{b}</span>
<button
disabled={i === 0}
onClick={() => {
const next = [...bucketOrder]
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
saveBucketOrder(next)
}}
title="Move earlier"
className="text-gray-400 hover:text-blue-600 disabled:opacity-25 px-0.5 leading-none"></button>
<button
disabled={i === bucketOrder.length - 1}
onClick={() => {
const next = [...bucketOrder]
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
saveBucketOrder(next)
}}
title="Move later"
className="text-gray-400 hover:text-blue-600 disabled:opacity-25 px-0.5 leading-none"></button>
</span>
))}
</div>
)}
<datalist id="pf-bucket-options">
<option value="Forecast" />
<option value="Prior Year" />
@ -381,6 +461,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium w-14 text-right">seq</th>
<th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium w-36">counts toward</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th>
@ -392,11 +473,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead>
<tbody>
{log.length === 0 && (
<tr><td colSpan={9} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
<tr><td colSpan={10} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{!showAddForm && !editingLogId && (
<tr className="border-t border-gray-100">
<td colSpan={9} className="p-0">
<td colSpan={10} className="p-0">
<button
onClick={() => setShowAddForm(true)}
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
@ -418,6 +499,16 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
>
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
<td className="px-3 py-2 text-right" onClick={e => e.stopPropagation()}>
<input
value={seqs[entry.id] ?? (entry.seq ?? '')}
onChange={e => 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" />
</td>
<td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
{entry.operation}
@ -451,7 +542,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</tr>
{isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={7} className="px-2 py-2">
<td colSpan={8} className="px-2 py-2">
<div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} />
</div>

View File

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