Let a segment say what it counts toward, and pivot on it

There was no way to express "these segments together are the forecast".
pf_iter cannot say it: it answers whether operations may write to a row, and
Open Orders is loaded as reference precisely so nothing adjusts it while
still being part of the forecast number. The two questions are independent,
so one cannot be derived from the other.

pf.log.bucket is the second axis. Free text with suggestions -- Forecast,
Prior Year, Prior Prior Year, Plan -- rather than an enum, so another banner
needs no migration. Blank by default, falling back in the pivot to the
segment's own name, so nothing changes until something is labelled.

/data and /agg emit it as pf_bucket beside pf_segment, through the pf.log
join that is already there. Set it per segment in the Baseline list, which is
where loads live now that the change log only shows adjustments.

Needs 01_schema.sql for the column and Generate SQL for /agg to select it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 00:59:00 -04:00
parent 15d9ecf319
commit 0049a391c0
6 changed files with 81 additions and 16 deletions

View File

@ -151,6 +151,10 @@ SELECT
,CASE WHEN l.operation IN ('baseline','reference') ,CASE WHEN l.operation IN ('baseline','reference')
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
ELSE '(adjustment)' END AS pf_segment ELSE '(adjustment)' END AS pf_segment
,COALESCE(NULLIF(l.bucket, ''),
CASE WHEN l.operation IN ('baseline','reference')
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
ELSE '(adjustment)' END) AS pf_bucket
,CASE WHEN l.operation IN ('baseline','reference') ,CASE WHEN l.operation IN ('baseline','reference')
THEN NULL THEN NULL
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note
@ -162,7 +166,8 @@ GROUP BY
${grain.groupCols('t.').join('\n ,')} ${grain.groupCols('t.').join('\n ,')}
,l.operation ,l.operation
,l.tag ,l.tag
,l.note`.trim(); ,l.note
,l.bucket`.trim();
} }
// grain columns + pf_gkey + summed measures, in the leading-comma style the // grain columns + pf_gkey + summed measures, in the leading-comma style the

View File

@ -131,22 +131,24 @@ 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 } = req.body; const { note, tag, bucket } = req.body;
if (note === undefined && tag === undefined) { if (note === undefined && tag === undefined && bucket === undefined) {
return res.status(400).json({ error: 'Nothing to update — send note and/or tag' }); return res.status(400).json({ error: 'Nothing to update — send note, tag and/or bucket' });
} }
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
// able to clear a field, which COALESCE on the value alone would ignore // able to clear a field, which COALESCE on the value alone would ignore
const result = await pool.query( const result = await pool.query(
`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
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),
] ]
); );
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' });

View File

@ -319,6 +319,12 @@ module.exports = function(pool) {
,CASE WHEN l.operation IN ('baseline','reference') ,CASE WHEN l.operation IN ('baseline','reference')
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
ELSE '(adjustment)' END AS pf_segment ELSE '(adjustment)' END AS pf_segment
-- what the segment counts towards, falling back to the segment
-- itself so an unlabelled one still reads as something
,COALESCE(NULLIF(l.bucket, ''),
CASE WHEN l.operation IN ('baseline','reference')
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
ELSE '(adjustment)' END) AS pf_bucket
,CASE WHEN l.operation IN ('baseline','reference') ,CASE WHEN l.operation IN ('baseline','reference')
THEN NULL THEN NULL
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note
@ -637,7 +643,7 @@ module.exports = function(pool) {
await client.query('COMMIT'); await client.query('COMMIT');
committed = true; committed = true;
const opLabel = (req.body.tag || '').trim() || note || null; const opLabel = (req.body.tag || '').trim() || note || null;
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'scale' })); const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'scale' }));
res.json({ res.json({
rows, rows,
rows_affected: rows.length, rows_affected: rows.length,
@ -697,7 +703,7 @@ module.exports = function(pool) {
await client.query('COMMIT'); await client.query('COMMIT');
committed = true; committed = true;
const opLabel = (req.body.tag || '').trim() || note || null; const opLabel = (req.body.tag || '').trim() || note || null;
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'recode' })); const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally { } finally {
if (!committed) try { await client.query('ROLLBACK'); } catch {} if (!committed) try { await client.query('ROLLBACK'); } catch {}
@ -754,7 +760,7 @@ module.exports = function(pool) {
await client.query('COMMIT'); await client.query('COMMIT');
committed = true; committed = true;
const opLabel = (req.body.tag || '').trim() || note || null; const opLabel = (req.body.tag || '').trim() || note || null;
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'clone' })); const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally { } finally {
if (!committed) try { await client.query('ROLLBACK'); } catch {} if (!committed) try { await client.query('ROLLBACK'); } catch {}

View File

@ -81,6 +81,19 @@ WHERE TRUE
AND note <> '' AND note <> ''
AND operation IN ('baseline', 'reference'); AND operation IN ('baseline', 'reference');
-- What a segment contributes to, independent of pf_iter.
--
-- pf_iter answers "can operations write to these rows"; bucket answers "does this
-- belong in the forecast number". Those are not the same question -- Open Orders is
-- loaded as reference so nothing adjusts it, yet it is part of the forecast -- so
-- neither can be derived from the other.
--
-- Free text with suggested values (Forecast / Prior Year / Prior Prior Year / Plan)
-- rather than an enum, so a new banner does not need a migration. Blank by default:
-- until a segment is labelled, the pivot falls back to showing its own name.
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS bucket text;
CREATE INDEX IF NOT EXISTS log_bucket_idx ON pf.log (bucket) WHERE bucket IS NOT NULL;
-- Master data for a dim_group: one row per key value, with its sibling columns. -- Master data for a dim_group: one row per key value, with its sibling columns.
-- --
-- The source is transactional and often a view over all history, so deriving a -- The source is transactional and often a view over all history, so deriving a

View File

@ -113,6 +113,28 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
loadLog() loadLog()
}, [versionId]) }, [versionId])
// A segment's banner: what it counts toward, independent of pf_iter. Held
// locally while typing so the field does not fight the fetched value, and
// written on blur.
const [buckets, setBuckets] = useState({})
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')
}
}
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'))
@ -335,11 +357,18 @@ 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">
<datalist id="pf-bucket-options">
<option value="Forecast" />
<option value="Prior Year" />
<option value="Prior Prior Year" />
<option value="Plan" />
</datalist>
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<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">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 text-right">rows</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">{log[0]?.value_col || 'value'}</th> <th className="px-3 py-1.5 font-medium text-right">{log[0]?.value_col || 'value'}</th>
<th className="px-3 py-1.5 font-medium">by</th> <th className="px-3 py-1.5 font-medium">by</th>
@ -349,11 +378,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead> </thead>
<tbody> <tbody>
{log.length === 0 && ( {log.length === 0 && (
<tr><td colSpan={8} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr> <tr><td colSpan={9} 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={8} className="p-0"> <td colSpan={9} 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"
@ -381,6 +410,16 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</span> </span>
{entry.note || <span className="text-gray-300"></span>} {entry.note || <span className="text-gray-300"></span>}
</td> </td>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input
value={buckets[entry.id] ?? entry.bucket ?? ''}
list="pf-bucket-options"
onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))}
onBlur={e => saveBucket(entry, e.target.value)}
placeholder="—"
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" />
</td>
<td className="px-3 py-2 text-right text-gray-700 font-mono"> <td className="px-3 py-2 text-right text-gray-700 font-mono">
{entry.row_count != null ? entry.row_count.toLocaleString() : '—'} {entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
</td> </td>
@ -398,7 +437,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={6} className="px-2 py-2"> <td colSpan={7} 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>

View File

@ -763,11 +763,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
? [ ? [
...grainMeta.map(c => c.cname), ...grainMeta.map(c => c.cname),
...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname), ...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname),
'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_note', 'pf_op', 'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
] ]
: [ : [
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname), ...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_note', 'pf_op', 'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
]) ])
const tableName = `fc_${vid}` const tableName = `fc_${vid}`