Store every log entry's segment and bucket instead of deriving them

pf_segment and pf_bucket were resolved at read time through a chain of
fallbacks (label, tag, note, the version's unlabeled_load; the version's
adjustment defaults), repeated in /agg, the /data cursor and the decorate
proc, so nothing but the pivot could say what bucket a row was in.

Now they are written onto pf.log when the entry is created: a load's
label is required and its bucket defaults to the label; an adjustment is
stamped with the version's adjustment_segment/adjustment_bucket, else
the built-ins. Reads become l.label / l.bucket. A log_named CHECK
guarantees both are present, and 01_schema.sql backfills existing
entries with the old read-time rule so no displayed name changes.

The Baseline page's "Fallback names" become "Defaults for new
adjustments"; unlabeled_load is no longer read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-24 22:35:49 -04:00
parent c0d58e1000
commit 2337951b65
6 changed files with 206 additions and 133 deletions

View File

@ -124,10 +124,12 @@ 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
does not itself carry:
- **`pf_segment`** — `pf.log.label`, else `tag`, else `note`, else `Unlabeled`;
`'99 - Adjustments'` for an adjustment that has no label of its own
- **`pf_segment`** — `pf.log.label`
- **`pf_bucket`** — `pf.log.bucket`
- **`pf_note`** — the free text on a scale/recode/clone; null on loads
Both names are **stored**, not derived — see §Every log entry is named.
They are deliberately separate: commingling a segment name with an adjustment note
makes neither pivotable. In grain mode `pf_logid` is part of the grain, so the join
adds no rows. The operation routes stamp the same two fields onto the rows they push
@ -146,45 +148,51 @@ typed by whoever names the segment. Nothing derives it.
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 after every *numbered* segment. That proviso is the
The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, the
name an adjustment is stamped with when its version sets no default, which keeps
those adjustments after every *numbered* segment. That proviso is the
whole scheme, not a caveat on it: ordering is string ordering, so `99` only lands
last once the loads carry `01`–`0n`, and an unnumbered segment sorts after it
(digits precede letters — `9` is `0x39`, `A` is `0x41`). The old `'(adjustment)'`
sorted *first* for the same reason read the other way, `(` being `0x28`.
Unlabelled loads read plain `Unlabeled` and so land at the very end, which is
where a segment nobody has named belongs. Labelling an adjustment's own log row
overrides the fallback, which is how one kind of adjustment is split out from the
rest.
Relabelling an adjustment's own log row afterwards is how one kind of adjustment
is split out from the rest.
### Hardcoded display names
### Every log entry is named
Every name the pivot can show that does not come from `pf.log`. If a segment or
bucket appears under a name nobody typed, it is one of these. All three are in
the `DISPLAY DEFAULTS` block at the top of `lib/sql_generator.js`, exported so
the `/data` cursor and the operation routes' incremental row stamps use the same
values the generated `/agg` does.
`pf.log.label` is a row's segment and `pf.log.bucket` what it counts toward, and
**every entry carries both** — the `log_named` CHECK refuses one without. So a
report gets the pivot's segment and bucket by joining the forecast table to
`pf.log` on `pf_logid` and selecting two columns; there is no rule to copy.
| constant | `pf.version` column | built-in | applies to |
|---|---|---|---|
| `ADJUSTMENT_SEGMENT` | `adjustment_segment` | `99 - Adjustments` | `pf_segment` for a scale/recode/clone with no `label` |
| `ADJUSTMENT_BUCKET` | `adjustment_bucket` | `04 - Forecast` | `pf_bucket` for a scale/recode/clone with no `bucket` |
| `UNLABELED_LOAD` | `unlabeled_load` | `Unlabeled` | `pf_segment` and `pf_bucket` for a load with no `label`, `tag` or `note` |
They are stamped when the entry is written:
Each is set per scenario on the Baseline page, under **Fallback names**; blank
falls back to the built-in. Anything typed on the log row overrides both, so
none of these appears once a segment is named.
- **A load** — `label` is required (the routes answer 400 without one); `bucket`
defaults to the label. `loadNames()` in `routes/operations.js`.
- **An adjustment** — scale, recode and clone have no name inputs, so they are
stamped with the version's `adjustment_segment` / `adjustment_bucket`, else the
built-ins `ADJUSTMENT_SEGMENT` = `99 - Adjustments` and `ADJUSTMENT_BUCKET` =
`04 - Forecast` (`ADJUSTMENT DEFAULTS` in `lib/sql_generator.js`).
`adjustmentNames()` resolves them once per request; they go into the template's
INSERT as `{{label}}` / `{{bucket}}` and onto the rows pushed back to the pivot.
**Why the join rather than a token.** `pf.sql` is keyed on
`(source_id, operation)` — one template shared by every version of a source — so
a value baked in at Generate SQL time could not vary by version, and
regenerating for one version would silently change the others. The names are
therefore read through `VERSION_JOIN` at query time, which also means changing
one takes effect on the next load with nothing regenerated.
The two version defaults are set on the Baseline page under **Defaults for new
adjustments**. Changing one names the *next* adjustment; existing entries keep
what they were written with. Renaming past entries is `PATCH /api/log/:logid`,
one at a time, which renames but refuses to clear.
The built-ins are still a convention guess: `ADJUSTMENT_BUCKET`'s `04 - ` only
suits one numbering. A version that numbers its buckets differently sets its
own rather than inheriting that.
**What this replaced.** The names used to be derived at read time: a load's
`label`, else `tag`, else `note`, else the version's `unlabeled_load`, else
`Unlabeled`; an adjustment's `label`, else the version default, else the
constant — read through a join on `pf.version`. That made the pivot the only
reader that knew what bucket a row was in. The same CASE was copied into
`/agg`, the `/data` cursor and `rlarp.load_osm_pf` in `/opt/forecast`, and any
direct query had to copy it again. It also meant editing a version's fallback
name silently renamed every past adjustment. The 2026-09-24 backfill in
`setup_sql/01_schema.sql` stamped the existing entries with that rule; before it
ran, the pre-backfill `label`/`bucket` values were saved to
`pf.bak_log_names_20260924`. `pf.version.unlabeled_load` is read by nothing now;
the column remains.
**What this replaced.** The prefix used to be computed client-side, as
Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from

View File

@ -14,93 +14,58 @@
// What the pivot shows for a row's segment and its bucket.
//
// Both are STORED on the row's pf.log entry -- l.label and l.bucket -- and read
// back as plain columns. Every entry carries both: pf.log's log_named CHECK
// refuses one without, so a report can join pf.log and select them with no rule
// of its own.
//
// They used to be derived at read time, through a chain of fallbacks (a load's
// label, else its tag, else its note, else the version's unlabeled_load; an
// adjustment's label, else the version's adjustment_segment, else a constant).
// That made the pivot the only reader that knew what bucket a row was in: the
// same CASE was repeated in /agg, the /data cursor and the decorate proc in
// /opt/forecast, and anything querying pf directly had to copy it again. It also
// meant editing a version's fallback name silently renamed every past adjustment.
//
// The ordering prefix is part of the stored text, not computed here. Perspective
// orders column groups by the value string, so "01 - Actual" is the only way an
// arbitrary order can be expressed -- and l.label is where a person types it.
// Nothing derives it, which is deliberate: an earlier design built the prefix from
// a separate seq column, as Perspective expressions on the client, and the prefix
// then existed only inside the pivot -- so every other reader disagreed with it,
// and a label that could not be expressed in ExprTK's printable-ASCII-per-byte
// string scanner could not be ordered at all. Stored text has neither problem.
//
// The single exception is the adjustment fallback, whose 99 keeps unlabelled
// adjustments last. Labelling an adjustment's log row overrides it, which is how
// one kind of adjustment is split out from the rest -- l.label rather than tag or
// note, so a segment name stays separable from adjustment commentary (pf_note).
//
// ---------------------------------------------------------------------------
// DISPLAY DEFAULTS -- every hardcoded name the pivot can show.
// ADJUSTMENT DEFAULTS -- what a scale/recode/clone is stamped with.
//
// These are the values a row falls back to when nobody has named it. They are
// the complete list: if a segment or bucket appears in the pivot under a name
// that is not in pf.log, it came from here. CLAUDE.md has the same list under
// "Hardcoded display names".
// An adjustment has no label or bucket input, so the route stamps these onto its
// log entry when it is written: the version's own adjustment_segment /
// adjustment_bucket when set, else the constants below. Resolved once, at write
// time -- changing a version's default afterwards names new adjustments, not old
// ones. Exported so the operation routes stamp the rows they push back to the
// pivot with the same values they wrote.
//
// They live on pf.version -- adjustment_segment, adjustment_bucket,
// unlabeled_load -- and the constants below are only the fallback for a version
// that has not set one. Read through a join at query time rather than
// substituted at generation: pf.sql templates are keyed on (source_id,
// operation) and shared by every version of a source, so a value baked in could
// not vary by version and regenerating for one would change the others.
//
// Exported because /data builds its own statement in routes/operations.js while
// /agg is generated here, and the two have to agree.
// A load has no default: its label is required, and its bucket is its label
// unless one is given (the routes enforce both).
// ---------------------------------------------------------------------------
// An adjustment with no label of its own. The 99 keeps it after every numbered
// segment -- ordering is string ordering, so this only works while the loads
// carry 01-0n. Labelling an adjustment's log row overrides it, which is how one
// kind of adjustment is split out from the rest.
// carry 01-0n.
const ADJUSTMENT_SEGMENT = '99 - Adjustments';
// What an adjustment counts toward. Prefixed to match the segments it adjusts:
// unprefixed it read 'Forecast' while the loads read '04 - Forecast', and the
// column split in two -- the adjustments sitting apart from the rows they
// adjust. The number is a guess at the convention in use, which is the clearest
// argument for making this per-version.
// What an adjustment counts toward. Prefixed to match the segments it adjusts,
// so the adjustments sit in the same column as the rows they adjust.
const ADJUSTMENT_BUCKET = '04 - Forecast';
// A load nobody named. No prefix, so it sorts after everything numbered --
// letters follow digits in ASCII. The old '(unlabeled load)' sorted *first*,
// since '(' is 0x28 and digits begin at 0x30.
const UNLABELED_LOAD = 'Unlabeled';
const LOAD_SEGMENT = `COALESCE(NULLIF(l.label, ''), NULLIF(l.tag, ''), NULLIF(l.note, ''),
NULLIF(v.unlabeled_load, ''), '${UNLABELED_LOAD}')`;
const SEGMENT_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
THEN ${LOAD_SEGMENT}
ELSE COALESCE(NULLIF(l.label, ''), NULLIF(v.adjustment_segment, ''), '${ADJUSTMENT_SEGMENT}')
END`;
// What the row counts towards. A load falls back to its own name until it is
// bucketed; an adjustment falls back to the forecast bucket, because that is
// what an adjustment is -- exclude_iters keeps operations off the reference
// segments, so there is no adjustment that is not part of the forecast.
const BUCKET_EXPR = `COALESCE(NULLIF(l.bucket, ''),
CASE WHEN l.operation IN ('baseline','reference')
THEN ${LOAD_SEGMENT}
ELSE COALESCE(NULLIF(v.adjustment_bucket, ''), '${ADJUSTMENT_BUCKET}')
END)`;
const SEGMENT_EXPR = `l.label`;
const BUCKET_EXPR = `l.bucket`;
const NOTE_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
THEN NULL
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
END`;
// Every pf.log column the two expressions above read, for /agg's GROUP BY: they
// Every pf.log column the expressions above read, for /agg's GROUP BY: they
// are functionally dependent on pf_logid, which is in the grain, but Postgres
// will not infer that.
const LABEL_GROUP_COLS = ['l.operation', 'l.label', 'l.tag', 'l.note', 'l.bucket',
'v.adjustment_segment', 'v.adjustment_bucket', 'v.unlabeled_load'];
// The version carries the fallback names, so every statement that reads the
// expressions above needs it in scope as `v`. LEFT, not inner: a forecast row
// whose log entry somehow has no version should still come back, named by the
// constants.
const VERSION_JOIN = `
LEFT JOIN pf.version v
ON v.id = l.version_id`;
const LABEL_GROUP_COLS = ['l.operation', 'l.label', 'l.tag', 'l.note', 'l.bucket'];
// wrap a column name in double quotes for safe use in SQL
function q(name) { return `"${name}"`; }
@ -319,7 +284,7 @@ SELECT
,l.operation AS pf_op
FROM {{fc_table}} t
LEFT JOIN pf.log l
ON l.id = t.pf_logid${VERSION_JOIN}
ON l.id = t.pf_logid
WHERE {{territory_clause}}
GROUP BY
${grain.groupCols('t.').join('\n ,')}
@ -431,8 +396,9 @@ SELECT count(*) AS rows_affected, (SELECT id FROM ilog) AS log_id FROM ins`.trim
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
RETURNING id
)
,base AS (
@ -459,8 +425,9 @@ ${opTail('ins')}`.trim();
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
RETURNING id
)
,src AS (
@ -509,8 +476,9 @@ ${opTail('allrows')}` : 'SELECT * FROM neg UNION ALL SELECT * FROM ins'}`.trim()
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
RETURNING id
)
,ins AS (
@ -557,8 +525,8 @@ function applyTokens(sql, tokens) {
// build a SQL WHERE clause string from a slice object
// only dimension columns are included; unrecognised keys are silently skipped
// pf_segment and pf_bucket are not columns on the forecast table -- they are
// computed at read time from the row's pf.log entry -- so a slice naming one
// cannot be compared directly. It resolves to a set of log ids instead, which is
// stored on the row's pf.log entry -- so a slice naming one cannot be compared
// directly. It resolves to a set of log ids instead, which is
// exact: the name lives on the log row, and every forecast row carries the
// pf_logid that points at it.
//
@ -576,7 +544,7 @@ function computedSlicePredicate(col, val, versionId) {
const vals = (Array.isArray(val) ? val : [val]).map(v => `'${esc(v)}'`).join(', ');
return `pf_logid IN (
SELECT l.id
FROM pf.log l${VERSION_JOIN}
FROM pf.log l
WHERE l.version_id = ${parseInt(versionId)}
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals})
)`;
@ -843,5 +811,5 @@ function esc(val) {
}
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS, VERSION_JOIN,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, UNLABELED_LOAD, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };

View File

@ -217,6 +217,14 @@ module.exports = function(pool) {
error: 'Nothing to update — send note, tag, bucket and/or label'
});
}
// Every entry carries a label and a bucket (pf.log's log_named CHECK), so
// they can be renamed but not cleared. Refused here for the message; the
// constraint would refuse it anyway.
for (const [field, value] of [['label', label], ['bucket', bucket]]) {
if (value !== undefined && !String(value ?? '').trim()) {
return res.status(400).json({ error: `The ${field} can be renamed but not cleared` });
}
}
try {
// Same rule as undo: your own entries, or an admin's. These are
// annotations, but label and bucket name the pivot's columns for

View File

@ -1,7 +1,7 @@
const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { fcTable } = require('../lib/utils');
@ -9,10 +9,11 @@ const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
// the names an adjustment's rows carry until the next load re-reads them
// through VERSION_JOIN. The per-version overrides live on pf.version, so
// stamping the built-in constants here made every fresh adjustment show as
// '99 - Adjustments' and jump to the version's own name on refresh.
// The segment and bucket an adjustment is stamped with: written onto its
// pf.log entry by the template, and onto the rows pushed back to the pivot,
// so the two agree. The version's own defaults when set, else the
// built-ins. Resolved at write time, so changing a version's default
// afterwards names new adjustments and leaves existing ones as they are.
function adjustmentNames(version) {
const pick = (v, dflt) => (v || '').trim() || dflt;
return {
@ -21,6 +22,21 @@ module.exports = function(pool) {
};
}
// The segment and bucket a load is stamped with. A load's label is its
// segment name and is required -- it used to fall back through tag, note and
// the version's unlabeled_load at read time, which left pf.log unable to say
// what a row was called. The bucket is the label unless one is given, which
// is how an unbucketed load has always read. Sends the 400 and returns null
// when there is no label.
function loadNames(label, bucket, res) {
const segment = String(label ?? '').trim();
if (!segment) {
res.status(400).json({ error: 'Give the segment a label — it is what the pivot shows and what reports group on' });
return null;
}
return { label: segment, bucket: String(bucket ?? '').trim() || segment };
}
async function runSQL(sql, client) {
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
return (client || pool).query(sql);
@ -486,7 +502,7 @@ module.exports = function(pool) {
,${NOTE_EXPR} AS pf_note
FROM ${tbl} t
LEFT JOIN pf.log l
ON l.id = t.pf_logid${VERSION_JOIN}
ON l.id = t.pf_logid
${terrAlias ? `WHERE ${terrAlias}` : ''}
`);
@ -590,6 +606,8 @@ module.exports = function(pool) {
const dateOffset = date_offset || '0 days';
if (!await assertInterval(dateOffset, res)) return;
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const names = loadNames(label, bucket, res);
if (!names) return;
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
@ -603,8 +621,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(label || ''),
bucket: esc(bucket || ''),
label: esc(names.label),
bucket: esc(names.bucket),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
@ -672,13 +690,15 @@ module.exports = function(pool) {
// so tag is always the latter and must survive an edit made for any
// other reason.
const keep = (sent, prior) => esc(sent ?? prior ?? '');
const names = loadNames(label ?? oldLog.label, bucket ?? oldLog.bucket, res);
if (!names) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: keep(label, oldLog.label),
bucket: keep(bucket, oldLog.bucket),
label: esc(names.label),
bucket: esc(names.bucket),
tag: keep(tag, oldLog.tag),
params: esc(paramsJson),
filter_clause: filterClause,
@ -749,6 +769,8 @@ module.exports = function(pool) {
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const names = loadNames(label, bucket, res);
if (!names) return;
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
@ -762,8 +784,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(label || ''),
bucket: esc(bucket || ''),
label: esc(names.label),
bucket: esc(names.bucket),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
@ -803,6 +825,7 @@ module.exports = function(pool) {
// reaches the target on its own and gets its own log entry.
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -825,6 +848,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({
slices: unit.slices,
apply_mode: applyMode,
@ -862,7 +887,6 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'scale' }));
res.json({
rows,
@ -899,6 +923,7 @@ module.exports = function(pool) {
const setClause = buildSetClause(ctx.dimCols, set);
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -913,6 +938,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
slice: esc(JSON.stringify(loggedSlice)),
where_clause: unit.where,
@ -936,7 +963,6 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally {
@ -1001,6 +1027,7 @@ module.exports = function(pool) {
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -1015,6 +1042,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
date_offset: dateOffset,
@ -1044,7 +1073,6 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally {

View File

@ -122,6 +122,54 @@ ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_segment text;
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_bucket text;
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS unlabeled_load text;
-- Every log entry is named. label is the row's segment and bucket what it counts
-- toward, both stored rather than derived, so a report reads them off pf.log with
-- no rule of its own. The routes stamp them on write: a load's label is required
-- and its bucket defaults to the label; an adjustment gets the version's
-- adjustment_segment / adjustment_bucket, else the built-ins in sql_generator.
--
-- The backfill names entries written before that, with the rule the pivot used to
-- apply at read time, so nothing already on screen changes name. It is the last
-- reader of pf.version.unlabeled_load; the column remains.
WITH
-- the read-time rule, verbatim
named AS (
SELECT
l.id
,CASE
WHEN l.operation IN ('baseline', 'reference')
THEN COALESCE(NULLIF(l.label, ''), NULLIF(l.tag, ''), NULLIF(l.note, ''), NULLIF(v.unlabeled_load, ''), 'Unlabeled')
ELSE COALESCE(NULLIF(l.label, ''), NULLIF(v.adjustment_segment, ''), '99 - Adjustments')
END AS label
,l.operation
,l.bucket
,v.adjustment_bucket
FROM
pf.log l
LEFT JOIN pf.version v
ON v.id = l.version_id
WHERE TRUE
AND (NULLIF(l.label, '') IS NULL OR NULLIF(l.bucket, '') IS NULL)
)
UPDATE pf.log l
SET
label = n.label
,bucket = COALESCE(
NULLIF(n.bucket, '')
,CASE
WHEN n.operation IN ('baseline', 'reference') THEN n.label
ELSE COALESCE(NULLIF(n.adjustment_bucket, ''), '04 - Forecast')
END
)
FROM
named n
WHERE TRUE
AND l.id = n.id;
ALTER TABLE pf.log DROP CONSTRAINT IF EXISTS log_named;
ALTER TABLE pf.log ADD CONSTRAINT log_named
CHECK (NULLIF(label, '') IS NOT NULL AND NULLIF(bucket, '') IS NOT NULL);
-- Where an account lands when it has no saved selection of its own.
--
-- Without it the app fell back to the newest version, which is whatever

View File

@ -176,6 +176,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function saveLogField(entry, field, value) {
const next = value.trim()
if (next === (entry[field] || '')) return
// every log entry carries both; the server refuses a blank one
if (!next) {
flash(`A segment's ${field} can be renamed but not cleared`, 'error')
setBuckets(b => { const { [entry.id]: _, ...rest } = b; return rest })
return
}
try {
const res = await fetch(`/api/log/${entry.id}`, {
method: 'PATCH',
@ -184,7 +190,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog()
flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared')
flash(`Saved — reload the Forecast view to see it`)
} catch (err) { flash(err.message, 'error') }
}
@ -421,6 +427,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function loadSegment() {
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
if (!segLabel.trim()) { flash('Give the segment a label', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = offset.trim() || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
@ -676,17 +683,19 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)}
{/* Fallback names. Not part of any segment -- they are what the pivot shows
for rows nobody has named, so they belong to the version rather than to
a log entry. Blank falls back to the built-in in sql_generator's
DISPLAY DEFAULTS block. */}
{/* Adjustment defaults. A scale, recode or clone has no label or bucket
input, so it is stamped with these when it is written. Written onto
the log entry, not read back through it: changing one names the next
adjustment and leaves the existing ones as they are. Blank falls back
to the built-in in sql_generator's ADJUSTMENT DEFAULTS block. */}
{versionId && (
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">Fallback names</span>
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">
Defaults for new adjustments <span className="normal-case tracking-normal text-gray-400">— existing adjustments keep the names they were written with</span>
</span>
{[
['adjustment_segment', 'Adjustment segment', '99 - Adjustments'],
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
['unlabeled_load', 'Unlabeled load', 'Unlabeled'],
].map(([field, label, builtin]) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-xs text-gray-500">{label}</label>
@ -961,10 +970,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<input
defaultValue={entry.label || ''}
key={`label-${entry.id}-${entry.label || ''}`}
onBlur={e => saveLogField(entry, 'label', e.target.value)}
onBlur={e => {
const typed = e.target.value
if (!typed.trim()) e.target.value = entry.label || ''
saveLogField(entry, 'label', typed)
}}
readOnly={!canEdit(entry)}
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
placeholder={entry.tag || entry.note || '—'}
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" />
@ -1320,12 +1333,12 @@ function SegmentForm({
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Label</label>
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
placeholder="defaults to the note" className={`${baseInp} text-sm py-1.5`} />
placeholder="required" className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Counts toward</label>
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
list="pf-bucket-options" placeholder="e.g. 02 - Forecast"
list="pf-bucket-options" placeholder="defaults to the label"
className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 flex-1 max-w-xs">