Drop the bucket reorder buttons, and document the new mechanism

bucket_order fed the ordering expressions and nothing else, so its up/down
list is gone. The datalist it shared state with stays, now offering the
buckets actually in use plus the four conventional names carried with their
prefixes -- a near-miss spelling silently splits a column in two, so the
options are worth more than they were.

saveBucket was a duplicate of saveLogField left behind by af9e6de's refactor;
the bucket cell goes through saveLogField like the label does. Both
confirmations now say to reload the Forecast view, which is true of a label
for the same reason it was true of a bucket: it is part of the aggregated row.

pf.log.seq and pf.version.bucket_order are no longer read anywhere. The
columns stay -- dropping them is not worth the migration, and nothing costs
anything by their being there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 22:45:11 -04:00
parent 7738b904bf
commit 03278eb091
2 changed files with 57 additions and 87 deletions

View File

@ -117,8 +117,8 @@ Aggregating to the grain the pivot actually displays is the load-time fix — me
`/data` and `/agg` both LEFT JOIN `pf.log` and emit two columns the forecast table `/data` and `/agg` both LEFT JOIN `pf.log` and emit two columns the forecast table
does not itself carry: does not itself carry:
- **`pf_segment`** — for a baseline or reference row, that load's label (`tag`, else - **`pf_segment`** — `pf.log.label`, else `tag`, else `note`, else `Unlabeled`;
`note`); `'(adjustment)'` for everything else `'99 - Adjustments'` for an adjustment that has no label of its own
- **`pf_note`** — the free text on a scale/recode/clone; null on loads - **`pf_note`** — the free text on a scale/recode/clone; null on loads
They are deliberately separate: commingling a segment name with an adjustment note They are deliberately separate: commingling a segment name with an adjustment note
@ -127,6 +127,42 @@ adds no rows. The operation routes stamp the same two fields onto the rows they
back incrementally, since those come from `RETURNING *` and would otherwise arrive back incrementally, since those come from `RETURNING *` and would otherwise arrive
without them. without them.
### Column order is stored text
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
alphabetical in neither direction and expressible as neither. A `"01 - "` prefix
is the only lever, and it lives in **`pf.log.label`** (and `pf.log.bucket`),
typed by whoever names the segment. Nothing derives it.
`SEGMENT_EXPR` / `BUCKET_EXPR` / `NOTE_EXPR` in `lib/sql_generator.js` are the
single definition, shared with the `/data` cursor in `routes/operations.js`
`/agg` is generated, `/data` is not, and the two have to agree.
The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, which
keeps unlabelled adjustments last. Labelling an adjustment's own log row
overrides it, which is how one kind of adjustment is split out from the rest.
Unlabelled loads read plain `Unlabeled` and need no ordinal, since letters follow
digits in ASCII — unlike the old `'(adjustment)'`, where `(` is `0x28` against
digits from `0x30` and so sorted *first*.
**What this replaced.** The prefix used to be computed client-side, as
Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from
`pf.log.seq` and `pf.version.bucket_order`. It ordered the pivot and nothing
else, so every other reader disagreed with it; `restore()` replaces
`expressions` wholesale, so it had to be re-applied after every layout load; and
ExprTK's string scanner tests each *byte* with `isprint()`, so a label
containing anything outside printable ASCII could not be ordered at all (`·` is
two bytes, of which `isprint(0xC2)` is false). `DEAD_ORDER_EXPRS` in
`Forecast.jsx` strips the expression names out of layouts saved under that
scheme. `pf.log.seq` and `pf.version.bucket_order` are no longer read; the
columns remain.
Reordering now needs a page reload to show, because the label is part of the
aggregated row rather than something the pivot can re-derive. Changing labels on
an existing source also needs **Generate SQL** re-run — the load templates that
write `label` and `bucket` onto the log row are stored in `pf.sql`.
### Forecast operations ### Forecast operations
POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums. POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums.

View File

@ -137,16 +137,19 @@ 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 // Buckets already in use on this version, for the datalist. There is no stored
// are carried into the pivot as a "01 · " prefix, because Perspective orders // bucket order any more: the order is whatever the typed text sorts as, so a
// column groups by the value string and no sort setting can express an // bucket is named "02 - Forecast" and that is the whole mechanism.
// arbitrary order. const [bucketsInUse, setBucketsInUse] = useState([])
const [bucketOrder, setBucketOrder] = useState([])
// Label and bucket are presentation, not definition: they change what the pivot // Label and bucket are presentation, not definition: they change what the pivot
// shows and what the segment counts toward, never which rows were loaded. So // shows and what the segment counts toward, never which rows were loaded. So
// they stay editable after adjustments exist, unlike the filters and offset, // they stay editable after adjustments exist, unlike the filters and offset,
// where an edit would silently recalibrate scales sized against the old rows. // where an edit would silently recalibrate scales sized against the old rows.
//
// Both are only read at load time, hence the reload in the confirmation: the
// label is part of the aggregated row the pivot holds, not something it can
// re-derive in place.
async function saveLogField(entry, field, value) { async function saveLogField(entry, field, value) {
const next = value.trim() const next = value.trim()
if (next === (entry[field] || '')) return if (next === (entry[field] || '')) return
@ -158,38 +161,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}) })
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog() loadLog()
flash('Saved') flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared')
} catch (err) { flash(err.message, 'error') }
}
async function saveBucket(entry, value) {
const next = value.trim()
if (next === (entry.bucket || '')) return
try {
const res = await fetch(`/api/log/${entry.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket: next }),
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog()
flash(next ? `Counts toward ${next} — reload the Forecast view to see it` : 'Banner cleared')
} 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') } } catch (err) { flash(err.message, 'error') }
} }
@ -198,13 +170,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
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 // Every bucket in use, adjustments included: typing one on a new segment
// name so labelling a new segment makes its bucket appear at the end, // should offer the ones already there rather than inviting a near-miss
// ready to be moved, rather than silently missing from the list. // spelling, which would silently split the column in two.
const inUse = [...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))] setBucketsInUse([...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))].sort())
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))])
}) })
} }
@ -426,47 +395,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<span>Segments loaded</span> <span>Segments loaded</span>
<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>
{/* 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" /> {[...new Set([...bucketsInUse,
<option value="Prior Year" /> '01 - Prior Prior Year', '02 - Prior Year',
<option value="Prior Prior Year" /> '03 - Plan', '04 - Forecast'])].map(b => (
<option value="Plan" /> <option key={b} value={b} />
))}
</datalist> </datalist>
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
@ -538,7 +472,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
value={buckets[entry.id] ?? entry.bucket ?? ''} value={buckets[entry.id] ?? entry.bucket ?? ''}
list="pf-bucket-options" list="pf-bucket-options"
onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))}
onBlur={e => saveBucket(entry, e.target.value)} onBlur={e => saveLogField(entry, 'bucket', e.target.value)}
placeholder="—" placeholder="—"
className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400
rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" />