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:
parent
ebe0288202
commit
916229bdab
@ -131,9 +131,9 @@ module.exports = function(pool) {
|
|||||||
// a closed version, where relabelling history is still legitimate.
|
// a closed version, where relabelling history is still legitimate.
|
||||||
router.patch('/log/:logid', async (req, res) => {
|
router.patch('/log/:logid', async (req, res) => {
|
||||||
const logId = parseInt(req.params.logid);
|
const logId = parseInt(req.params.logid);
|
||||||
const { note, tag, bucket } = req.body;
|
const { note, tag, bucket, seq } = req.body;
|
||||||
if (note === undefined && tag === undefined && bucket === undefined) {
|
if (note === undefined && tag === undefined && bucket === undefined && seq === undefined) {
|
||||||
return res.status(400).json({ error: 'Nothing to update — send note, tag and/or bucket' });
|
return res.status(400).json({ error: 'Nothing to update — send note, tag, bucket and/or seq' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// COALESCE on the flag, not the value: an explicit null or '' must be
|
// 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
|
`UPDATE pf.log SET
|
||||||
note = CASE WHEN $2::bool THEN $3::text ELSE note END,
|
note = CASE WHEN $2::bool THEN $3::text ELSE note END,
|
||||||
tag = CASE WHEN $4::bool THEN $5::text ELSE tag 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 *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[
|
[
|
||||||
logId,
|
logId,
|
||||||
note !== undefined, note === undefined ? null : (String(note).trim() || null),
|
note !== undefined, note === undefined ? null : (String(note).trim() || null),
|
||||||
tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
|
tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
|
||||||
bucket !== undefined, bucket === undefined ? null : (String(bucket).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' });
|
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
||||||
|
|||||||
@ -309,20 +309,26 @@ ${colDefs},
|
|||||||
|
|
||||||
// update version name, description, or exclude_iters
|
// update version name, description, or exclude_iters
|
||||||
router.put('/versions/:id', async (req, res) => {
|
router.put('/versions/:id', async (req, res) => {
|
||||||
const { name, description, exclude_iters } = req.body;
|
const { name, description, exclude_iters, bucket_order } = req.body;
|
||||||
try {
|
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(`
|
const result = await pool.query(`
|
||||||
UPDATE pf.version SET
|
UPDATE pf.version SET
|
||||||
name = COALESCE($2, name),
|
name = COALESCE($2, name),
|
||||||
description = COALESCE($3, description),
|
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
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [
|
`, [
|
||||||
req.params.id,
|
req.params.id,
|
||||||
name || null,
|
name || null,
|
||||||
description || 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) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Version not found' });
|
return res.status(404).json({ error: 'Version not found' });
|
||||||
|
|||||||
@ -81,6 +81,20 @@ WHERE TRUE
|
|||||||
AND note <> ''
|
AND note <> ''
|
||||||
AND operation IN ('baseline', 'reference');
|
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.
|
-- What a segment contributes to, independent of pf_iter.
|
||||||
--
|
--
|
||||||
-- pf_iter answers "can operations write to these rows"; bucket answers "does this
|
-- pf_iter answers "can operations write to these rows"; bucket answers "does this
|
||||||
|
|||||||
@ -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
|
// locally while typing so the field does not fight the fetched value, and
|
||||||
// written on blur.
|
// written on blur.
|
||||||
const [buckets, setBuckets] = useState({})
|
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) {
|
async function saveBucket(entry, value) {
|
||||||
const next = value.trim()
|
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() {
|
function loadLog() {
|
||||||
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
||||||
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
||||||
setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation)))
|
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>
|
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-xs">
|
<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">
|
<datalist id="pf-bucket-options">
|
||||||
<option value="Forecast" />
|
<option value="Forecast" />
|
||||||
<option value="Prior Year" />
|
<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">
|
<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 w-6"></th>
|
||||||
<th className="px-3 py-1.5 font-medium">#</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">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 w-36">counts toward</th>
|
||||||
<th className="px-3 py-1.5 font-medium text-right">rows</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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{log.length === 0 && (
|
{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 && (
|
{!showAddForm && !editingLogId && (
|
||||||
<tr className="border-t border-gray-100">
|
<tr className="border-t border-gray-100">
|
||||||
<td colSpan={9} className="p-0">
|
<td colSpan={10} className="p-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
|
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 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-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">
|
<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'}`}>
|
<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}
|
{entry.operation}
|
||||||
@ -451,7 +542,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</tr>
|
</tr>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
<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">
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
<SegmentForm mode="view" {...view} filterCols={filterCols} />
|
<SegmentForm mode="view" {...view} filterCols={filterCols} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,6 +30,68 @@ function cleanLayout(cfg, validCols) {
|
|||||||
return c
|
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 }) {
|
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
|
||||||
const { dark } = useTheme()
|
const { dark } = useTheme()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -506,7 +568,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
try {
|
try {
|
||||||
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
|
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
|
||||||
const map = {}
|
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)
|
setLogMeta(map)
|
||||||
} catch { setLogMeta({}) }
|
} catch { setLogMeta({}) }
|
||||||
}
|
}
|
||||||
@ -550,6 +615,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}, [sourceId, versionId])
|
}, [sourceId, versionId])
|
||||||
|
|
||||||
useEffect(() => { refreshLogMeta(versionId) }, [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])
|
useEffect(() => { refreshTags(sourceId) }, [sourceId])
|
||||||
|
|
||||||
// Stream an Arrow IPC endpoint into one buffer, reporting download progress.
|
// 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)
|
await viewer.load(tableRef.current)
|
||||||
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||||||
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
|
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
|
||||||
|
await syncOrderExpressions()
|
||||||
|
|
||||||
// restore last-used layout or build default
|
// restore last-used layout or build default
|
||||||
// Strip cfg.table — table is already loaded by reference above; a stale name
|
// 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
|
// 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
|
// 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
|
// needs -- but the grand total sums across the split, and when the split is
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user