From 0049a391c08aed03889ef4007154416c3daf6a96 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 00:59:00 -0400 Subject: [PATCH] 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) --- lib/sql_generator.js | 7 +++++- routes/log.js | 16 ++++++++------ routes/operations.js | 12 ++++++++--- setup_sql/01_schema.sql | 13 +++++++++++ ui/src/views/Baseline.jsx | 45 ++++++++++++++++++++++++++++++++++++--- ui/src/views/Forecast.jsx | 4 ++-- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/lib/sql_generator.js b/lib/sql_generator.js index a6b0076..4a9873d 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -151,6 +151,10 @@ SELECT ,CASE WHEN l.operation IN ('baseline','reference') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') 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') THEN NULL ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note @@ -162,7 +166,8 @@ GROUP BY ${grain.groupCols('t.').join('\n ,')} ,l.operation ,l.tag - ,l.note`.trim(); + ,l.note + ,l.bucket`.trim(); } // grain columns + pf_gkey + summed measures, in the leading-comma style the diff --git a/routes/log.js b/routes/log.js index 46772c1..8d2d703 100644 --- a/routes/log.js +++ b/routes/log.js @@ -131,22 +131,24 @@ 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 } = req.body; - if (note === undefined && tag === undefined) { - return res.status(400).json({ error: 'Nothing to update — send note and/or tag' }); + 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' }); } try { // 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 const result = await pool.query( `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 + 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 WHERE id = $1 RETURNING *`, [ logId, - note !== undefined, note === undefined ? null : (String(note).trim() || null), - tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), + 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), ] ); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); diff --git a/routes/operations.js b/routes/operations.js index 2c97cbb..3050444 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -319,6 +319,12 @@ module.exports = function(pool) { ,CASE WHEN l.operation IN ('baseline','reference') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') 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') THEN NULL ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note @@ -637,7 +643,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; 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({ rows, rows_affected: rows.length, @@ -697,7 +703,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; 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 }); } finally { if (!committed) try { await client.query('ROLLBACK'); } catch {} @@ -754,7 +760,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; 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 }); } finally { if (!committed) try { await client.query('ROLLBACK'); } catch {} diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index 0ea789c..6315893 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -81,6 +81,19 @@ WHERE TRUE AND note <> '' 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. -- -- The source is transactional and often a view over all history, so deriving a diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 7913c78..cab5835 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -113,6 +113,28 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio loadLog() }, [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() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference')) @@ -335,11 +357,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio + + + @@ -349,11 +378,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - + )} {!showAddForm && !editingLogId && ( - + @@ -398,7 +437,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {isOpen && ( -
# notecounts toward rows {log[0]?.value_col || 'value'} by
No segments loaded yet
No segments loaded yet
+ e.stopPropagation()}> + 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" /> + {entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
+
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index e1718d5..e28c4f9 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -763,11 +763,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio ? [ ...grainMeta.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), - '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}`