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 && (
+