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:
parent
c0d58e1000
commit
2337951b65
70
CLAUDE.md
70
CLAUDE.md
@ -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
|
`/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`** — `pf.log.label`, else `tag`, else `note`, else `Unlabeled`;
|
- **`pf_segment`** — `pf.log.label`
|
||||||
`'99 - Adjustments'` for an adjustment that has no label of its own
|
- **`pf_bucket`** — `pf.log.bucket`
|
||||||
- **`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
|
||||||
|
|
||||||
|
Both names are **stored**, not derived — see §Every log entry is named.
|
||||||
|
|
||||||
They are deliberately separate: commingling a segment name with an adjustment note
|
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
|
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
|
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` —
|
single definition, shared with the `/data` cursor in `routes/operations.js` —
|
||||||
`/agg` is generated, `/data` is not, and the two have to agree.
|
`/agg` is generated, `/data` is not, and the two have to agree.
|
||||||
|
|
||||||
The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, which
|
The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, the
|
||||||
keeps unlabelled adjustments after every *numbered* segment. That proviso is 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
|
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
|
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)'`
|
(digits precede letters — `9` is `0x39`, `A` is `0x41`). The old `'(adjustment)'`
|
||||||
sorted *first* for the same reason read the other way, `(` being `0x28`.
|
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
|
Relabelling an adjustment's own log row afterwards is how one kind of adjustment
|
||||||
where a segment nobody has named belongs. Labelling an adjustment's own log row
|
is split out from the rest.
|
||||||
overrides the fallback, which 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
|
`pf.log.label` is a row's segment and `pf.log.bucket` what it counts toward, and
|
||||||
bucket appears under a name nobody typed, it is one of these. All three are in
|
**every entry carries both** — the `log_named` CHECK refuses one without. So a
|
||||||
the `DISPLAY DEFAULTS` block at the top of `lib/sql_generator.js`, exported so
|
report gets the pivot's segment and bucket by joining the forecast table to
|
||||||
the `/data` cursor and the operation routes' incremental row stamps use the same
|
`pf.log` on `pf_logid` and selecting two columns; there is no rule to copy.
|
||||||
values the generated `/agg` does.
|
|
||||||
|
|
||||||
| constant | `pf.version` column | built-in | applies to |
|
They are stamped when the entry is written:
|
||||||
|---|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
Each is set per scenario on the Baseline page, under **Fallback names**; blank
|
- **A load** — `label` is required (the routes answer 400 without one); `bucket`
|
||||||
falls back to the built-in. Anything typed on the log row overrides both, so
|
defaults to the label. `loadNames()` in `routes/operations.js`.
|
||||||
none of these appears once a segment is named.
|
- **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
|
The two version defaults are set on the Baseline page under **Defaults for new
|
||||||
`(source_id, operation)` — one template shared by every version of a source — so
|
adjustments**. Changing one names the *next* adjustment; existing entries keep
|
||||||
a value baked in at Generate SQL time could not vary by version, and
|
what they were written with. Renaming past entries is `PATCH /api/log/:logid`,
|
||||||
regenerating for one version would silently change the others. The names are
|
one at a time, which renames but refuses to clear.
|
||||||
therefore read through `VERSION_JOIN` at query time, which also means changing
|
|
||||||
one takes effect on the next load with nothing regenerated.
|
|
||||||
|
|
||||||
The built-ins are still a convention guess: `ADJUSTMENT_BUCKET`'s `04 - ` only
|
**What this replaced.** The names used to be derived at read time: a load's
|
||||||
suits one numbering. A version that numbers its buckets differently sets its
|
`label`, else `tag`, else `note`, else the version's `unlabeled_load`, else
|
||||||
own rather than inheriting that.
|
`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
|
**What this replaced.** The prefix used to be computed client-side, as
|
||||||
Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from
|
Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from
|
||||||
|
|||||||
@ -14,93 +14,58 @@
|
|||||||
|
|
||||||
// What the pivot shows for a row's segment and its bucket.
|
// 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
|
// 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
|
// 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.
|
// 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
|
// An adjustment has no label or bucket input, so the route stamps these onto its
|
||||||
// the complete list: if a segment or bucket appears in the pivot under a name
|
// log entry when it is written: the version's own adjustment_segment /
|
||||||
// that is not in pf.log, it came from here. CLAUDE.md has the same list under
|
// adjustment_bucket when set, else the constants below. Resolved once, at write
|
||||||
// "Hardcoded display names".
|
// 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,
|
// A load has no default: its label is required, and its bucket is its label
|
||||||
// unlabeled_load -- and the constants below are only the fallback for a version
|
// unless one is given (the routes enforce both).
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// An adjustment with no label of its own. The 99 keeps it after every numbered
|
// 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
|
// 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
|
// carry 01-0n.
|
||||||
// kind of adjustment is split out from the rest.
|
|
||||||
const ADJUSTMENT_SEGMENT = '99 - Adjustments';
|
const ADJUSTMENT_SEGMENT = '99 - Adjustments';
|
||||||
|
|
||||||
// What an adjustment counts toward. Prefixed to match the segments it adjusts:
|
// What an adjustment counts toward. Prefixed to match the segments it adjusts,
|
||||||
// unprefixed it read 'Forecast' while the loads read '04 - Forecast', and the
|
// so the adjustments sit in the same column as the rows they adjust.
|
||||||
// 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.
|
|
||||||
const ADJUSTMENT_BUCKET = '04 - Forecast';
|
const ADJUSTMENT_BUCKET = '04 - Forecast';
|
||||||
|
|
||||||
// A load nobody named. No prefix, so it sorts after everything numbered --
|
const SEGMENT_EXPR = `l.label`;
|
||||||
// letters follow digits in ASCII. The old '(unlabeled load)' sorted *first*,
|
const BUCKET_EXPR = `l.bucket`;
|
||||||
// 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 NOTE_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
|
const NOTE_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
|
||||||
THEN NULL
|
THEN NULL
|
||||||
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
|
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
|
||||||
END`;
|
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
|
// are functionally dependent on pf_logid, which is in the grain, but Postgres
|
||||||
// will not infer that.
|
// will not infer that.
|
||||||
const LABEL_GROUP_COLS = ['l.operation', 'l.label', 'l.tag', 'l.note', 'l.bucket',
|
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`;
|
|
||||||
|
|
||||||
// wrap a column name in double quotes for safe use in SQL
|
// wrap a column name in double quotes for safe use in SQL
|
||||||
function q(name) { return `"${name}"`; }
|
function q(name) { return `"${name}"`; }
|
||||||
@ -319,7 +284,7 @@ SELECT
|
|||||||
,l.operation AS pf_op
|
,l.operation AS pf_op
|
||||||
FROM {{fc_table}} t
|
FROM {{fc_table}} t
|
||||||
LEFT JOIN pf.log l
|
LEFT JOIN pf.log l
|
||||||
ON l.id = t.pf_logid${VERSION_JOIN}
|
ON l.id = t.pf_logid
|
||||||
WHERE {{territory_clause}}
|
WHERE {{territory_clause}}
|
||||||
GROUP BY
|
GROUP BY
|
||||||
${grain.groupCols('t.').join('\n ,')}
|
${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 `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, 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}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
||||||
|
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,base AS (
|
,base AS (
|
||||||
@ -459,8 +425,9 @@ ${opTail('ins')}`.trim();
|
|||||||
return `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, 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}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
||||||
|
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,src AS (
|
,src AS (
|
||||||
@ -509,8 +476,9 @@ ${opTail('allrows')}` : 'SELECT * FROM neg UNION ALL SELECT * FROM ins'}`.trim()
|
|||||||
return `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, 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}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
||||||
|
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,ins AS (
|
,ins AS (
|
||||||
@ -557,8 +525,8 @@ function applyTokens(sql, tokens) {
|
|||||||
// build a SQL WHERE clause string from a slice object
|
// build a SQL WHERE clause string from a slice object
|
||||||
// only dimension columns are included; unrecognised keys are silently skipped
|
// only dimension columns are included; unrecognised keys are silently skipped
|
||||||
// pf_segment and pf_bucket are not columns on the forecast table -- they are
|
// 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
|
// stored on the row's pf.log entry -- so a slice naming one cannot be compared
|
||||||
// cannot be compared directly. It resolves to a set of log ids instead, which is
|
// 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
|
// exact: the name lives on the log row, and every forecast row carries the
|
||||||
// pf_logid that points at it.
|
// 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(', ');
|
const vals = (Array.isArray(val) ? val : [val]).map(v => `'${esc(v)}'`).join(', ');
|
||||||
return `pf_logid IN (
|
return `pf_logid IN (
|
||||||
SELECT l.id
|
SELECT l.id
|
||||||
FROM pf.log l${VERSION_JOIN}
|
FROM pf.log l
|
||||||
WHERE l.version_id = ${parseInt(versionId)}
|
WHERE l.version_id = ${parseInt(versionId)}
|
||||||
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals})
|
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals})
|
||||||
)`;
|
)`;
|
||||||
@ -843,5 +811,5 @@ function esc(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause,
|
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause,
|
||||||
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS, VERSION_JOIN,
|
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS,
|
||||||
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, UNLABELED_LOAD, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
||||||
|
|||||||
@ -217,6 +217,14 @@ module.exports = function(pool) {
|
|||||||
error: 'Nothing to update — send note, tag, bucket and/or label'
|
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 {
|
try {
|
||||||
// Same rule as undo: your own entries, or an admin's. These are
|
// Same rule as undo: your own entries, or an admin's. These are
|
||||||
// annotations, but label and bucket name the pivot's columns for
|
// annotations, but label and bucket name the pivot's columns for
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||||
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
|
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');
|
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
|
||||||
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
|
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
@ -9,10 +9,11 @@ const { fcTable } = require('../lib/utils');
|
|||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// the names an adjustment's rows carry until the next load re-reads them
|
// The segment and bucket an adjustment is stamped with: written onto its
|
||||||
// through VERSION_JOIN. The per-version overrides live on pf.version, so
|
// pf.log entry by the template, and onto the rows pushed back to the pivot,
|
||||||
// stamping the built-in constants here made every fresh adjustment show as
|
// so the two agree. The version's own defaults when set, else the
|
||||||
// '99 - Adjustments' and jump to the version's own name on refresh.
|
// 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) {
|
function adjustmentNames(version) {
|
||||||
const pick = (v, dflt) => (v || '').trim() || dflt;
|
const pick = (v, dflt) => (v || '').trim() || dflt;
|
||||||
return {
|
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) {
|
async function runSQL(sql, client) {
|
||||||
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
|
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
|
||||||
return (client || pool).query(sql);
|
return (client || pool).query(sql);
|
||||||
@ -486,7 +502,7 @@ module.exports = function(pool) {
|
|||||||
,${NOTE_EXPR} AS pf_note
|
,${NOTE_EXPR} AS pf_note
|
||||||
FROM ${tbl} t
|
FROM ${tbl} t
|
||||||
LEFT JOIN pf.log l
|
LEFT JOIN pf.log l
|
||||||
ON l.id = t.pf_logid${VERSION_JOIN}
|
ON l.id = t.pf_logid
|
||||||
${terrAlias ? `WHERE ${terrAlias}` : ''}
|
${terrAlias ? `WHERE ${terrAlias}` : ''}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -590,6 +606,8 @@ module.exports = function(pool) {
|
|||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
if (!await assertInterval(dateOffset, res)) return;
|
if (!await assertInterval(dateOffset, res)) return;
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
|
const names = loadNames(label, bucket, res);
|
||||||
|
if (!names) return;
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
@ -603,8 +621,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(label || ''),
|
label: esc(names.label),
|
||||||
bucket: esc(bucket || ''),
|
bucket: esc(names.bucket),
|
||||||
tag: esc(tag || ''),
|
tag: esc(tag || ''),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
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
|
// so tag is always the latter and must survive an edit made for any
|
||||||
// other reason.
|
// other reason.
|
||||||
const keep = (sent, prior) => esc(sent ?? prior ?? '');
|
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, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: keep(label, oldLog.label),
|
label: esc(names.label),
|
||||||
bucket: keep(bucket, oldLog.bucket),
|
bucket: esc(names.bucket),
|
||||||
tag: keep(tag, oldLog.tag),
|
tag: keep(tag, oldLog.tag),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
filter_clause: filterClause,
|
||||||
@ -749,6 +769,8 @@ module.exports = function(pool) {
|
|||||||
const pf_user = sessionUser(req);
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
|
const names = loadNames(label, bucket, res);
|
||||||
|
if (!names) return;
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
@ -762,8 +784,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(label || ''),
|
label: esc(names.label),
|
||||||
bucket: esc(bucket || ''),
|
bucket: esc(names.bucket),
|
||||||
tag: esc(tag || ''),
|
tag: esc(tag || ''),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
filter_clause: filterClause,
|
||||||
@ -803,6 +825,7 @@ module.exports = function(pool) {
|
|||||||
// reaches the target on its own and gets its own log entry.
|
// reaches the target on its own and gets its own log entry.
|
||||||
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
|
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
|
||||||
|
|
||||||
|
const names = adjustmentNames(ctx.version);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -825,6 +848,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
|
label: esc(names.pf_segment),
|
||||||
|
bucket: esc(names.pf_bucket),
|
||||||
params: esc(JSON.stringify({
|
params: esc(JSON.stringify({
|
||||||
slices: unit.slices,
|
slices: unit.slices,
|
||||||
apply_mode: applyMode,
|
apply_mode: applyMode,
|
||||||
@ -862,7 +887,6 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'scale' }));
|
||||||
res.json({
|
res.json({
|
||||||
rows,
|
rows,
|
||||||
@ -899,6 +923,7 @@ module.exports = function(pool) {
|
|||||||
const setClause = buildSetClause(ctx.dimCols, set);
|
const setClause = buildSetClause(ctx.dimCols, set);
|
||||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
||||||
|
|
||||||
|
const names = adjustmentNames(ctx.version);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -913,6 +938,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
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 })),
|
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
|
||||||
slice: esc(JSON.stringify(loggedSlice)),
|
slice: esc(JSON.stringify(loggedSlice)),
|
||||||
where_clause: unit.where,
|
where_clause: unit.where,
|
||||||
@ -936,7 +963,6 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, 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 {
|
||||||
@ -1001,6 +1027,7 @@ module.exports = function(pool) {
|
|||||||
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
|
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
|
||||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
||||||
|
|
||||||
|
const names = adjustmentNames(ctx.version);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -1015,6 +1042,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
|
label: esc(names.pf_segment),
|
||||||
|
bucket: esc(names.pf_bucket),
|
||||||
params: esc(JSON.stringify({
|
params: esc(JSON.stringify({
|
||||||
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
|
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
|
||||||
date_offset: dateOffset,
|
date_offset: dateOffset,
|
||||||
@ -1044,7 +1073,6 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, 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 {
|
||||||
|
|||||||
@ -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 adjustment_bucket text;
|
||||||
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS unlabeled_load 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.
|
-- 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
|
-- Without it the app fell back to the newest version, which is whatever
|
||||||
|
|||||||
@ -176,6 +176,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
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
|
||||||
|
// 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 {
|
try {
|
||||||
const res = await fetch(`/api/log/${entry.id}`, {
|
const res = await fetch(`/api/log/${entry.id}`, {
|
||||||
method: 'PATCH',
|
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 }
|
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||||
loadLog()
|
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') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,6 +427,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
async function loadSegment() {
|
async function loadSegment() {
|
||||||
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
|
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
|
||||||
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
|
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 isRef = segType === 'reference'
|
||||||
const offsetStr = offset.trim() || '0 days'
|
const offsetStr = offset.trim() || '0 days'
|
||||||
const endpoint = isRef ? 'reference' : 'baseline'
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
@ -676,17 +683,19 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback names. Not part of any segment -- they are what the pivot shows
|
{/* Adjustment defaults. A scale, recode or clone has no label or bucket
|
||||||
for rows nobody has named, so they belong to the version rather than to
|
input, so it is stamped with these when it is written. Written onto
|
||||||
a log entry. Blank falls back to the built-in in sql_generator's
|
the log entry, not read back through it: changing one names the next
|
||||||
DISPLAY DEFAULTS block. */}
|
adjustment and leaves the existing ones as they are. Blank falls back
|
||||||
|
to the built-in in sql_generator's ADJUSTMENT DEFAULTS block. */}
|
||||||
{versionId && (
|
{versionId && (
|
||||||
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
|
<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_segment', 'Adjustment segment', '99 - Adjustments'],
|
||||||
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
|
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
|
||||||
['unlabeled_load', 'Unlabeled load', 'Unlabeled'],
|
|
||||||
].map(([field, label, builtin]) => (
|
].map(([field, label, builtin]) => (
|
||||||
<div key={field} className="flex flex-col gap-1">
|
<div key={field} className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500">{label}</label>
|
<label className="text-xs text-gray-500">{label}</label>
|
||||||
@ -961,10 +970,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
<input
|
<input
|
||||||
defaultValue={entry.label || ''}
|
defaultValue={entry.label || ''}
|
||||||
key={`label-${entry.id}-${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)}
|
readOnly={!canEdit(entry)}
|
||||||
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
|
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
|
className="w-full border border-transparent hover:border-gray-200
|
||||||
focus:border-blue-400 rounded px-1 py-0.5 text-xs
|
focus:border-blue-400 rounded px-1 py-0.5 text-xs
|
||||||
focus:outline-none bg-transparent" />
|
focus:outline-none bg-transparent" />
|
||||||
@ -1320,12 +1333,12 @@ function SegmentForm({
|
|||||||
<div className="flex flex-col gap-1 max-w-xs">
|
<div className="flex flex-col gap-1 max-w-xs">
|
||||||
<label className="text-xs text-gray-500">Label</label>
|
<label className="text-xs text-gray-500">Label</label>
|
||||||
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
|
<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>
|
||||||
<div className="flex flex-col gap-1 max-w-xs">
|
<div className="flex flex-col gap-1 max-w-xs">
|
||||||
<label className="text-xs text-gray-500">Counts toward</label>
|
<label className="text-xs text-gray-500">Counts toward</label>
|
||||||
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
|
<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`} />
|
className={`${baseInp} text-sm py-1.5`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user