Merge segment labels, stored ordering, and slice fidelity
The pivot's column order is stored text now, typed into pf.log.label, rather than a prefix computed in the browser from a seq column -- which ordered the pivot and nothing else, had to be re-applied after every layout load, and could not express a label outside printable ASCII. 250 lines of expression machinery go with it. The fallback names an unnamed row shows live on pf.version, editable per scenario. A segment edit no longer wipes the annotations it was not handed. Slices mean what they say: pf_segment and pf_bucket resolve to log ids instead of being dropped, the measure name no longer leaks in from a collapsed column, and the pivot's filter scopes both the ledger and the write. Each of those was a case of the panel showing one number and the operation changing a wider set. The ledger names its lines the way the rest of the app does, leads with what cannot move, and breaks that out per segment.
This commit is contained in:
commit
74fd030638
74
CLAUDE.md
74
CLAUDE.md
@ -117,8 +117,8 @@ Aggregating to the grain the pivot actually displays is the load-time fix — me
|
||||
`/data` and `/agg` both LEFT JOIN `pf.log` and emit two columns the forecast table
|
||||
does not itself carry:
|
||||
|
||||
- **`pf_segment`** — for a baseline or reference row, that load's label (`tag`, else
|
||||
`note`); `'(adjustment)'` for everything else
|
||||
- **`pf_segment`** — `pf.log.label`, else `tag`, else `note`, else `Unlabeled`;
|
||||
`'99 - Adjustments'` for an adjustment that has no label of its own
|
||||
- **`pf_note`** — the free text on a scale/recode/clone; null on loads
|
||||
|
||||
They are deliberately separate: commingling a segment name with an adjustment note
|
||||
@ -127,6 +127,76 @@ adds no rows. The operation routes stamp the same two fields onto the rows they
|
||||
back incrementally, since those come from `RETURNING *` and would otherwise arrive
|
||||
without them.
|
||||
|
||||
### Column order is stored text
|
||||
|
||||
Perspective orders column groups by the value string, and `SortDir`'s `col asc` /
|
||||
`col desc` only reverses that — so Prior Year → Plan → Actual → Forecast is
|
||||
alphabetical in neither direction and expressible as neither. A `"01 - "` prefix
|
||||
is the only lever, and it lives in **`pf.log.label`** (and `pf.log.bucket`),
|
||||
typed by whoever names the segment. Nothing derives it.
|
||||
|
||||
`SEGMENT_EXPR` / `BUCKET_EXPR` / `NOTE_EXPR` in `lib/sql_generator.js` are the
|
||||
single definition, shared with the `/data` cursor in `routes/operations.js` —
|
||||
`/agg` is generated, `/data` is not, and the two have to agree.
|
||||
|
||||
The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, which
|
||||
keeps unlabelled adjustments 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.
|
||||
|
||||
### Hardcoded display names
|
||||
|
||||
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.
|
||||
|
||||
| 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` |
|
||||
|
||||
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.
|
||||
|
||||
**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 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 prefix used to be computed client-side, as
|
||||
Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from
|
||||
`pf.log.seq` and `pf.version.bucket_order`. It ordered the pivot and nothing
|
||||
else, so every other reader disagreed with it; `restore()` replaces
|
||||
`expressions` wholesale, so it had to be re-applied after every layout load; and
|
||||
ExprTK's string scanner tests each *byte* with `isprint()`, so a label
|
||||
containing anything outside printable ASCII could not be ordered at all (`·` is
|
||||
two bytes, of which `isprint(0xC2)` is false). `DEAD_ORDER_EXPRS` in
|
||||
`Forecast.jsx` strips the expression names out of layouts saved under that
|
||||
scheme. `pf.log.seq` and `pf.version.bucket_order` are no longer read; the
|
||||
columns remain.
|
||||
|
||||
Relabelling now needs a page reload to show, because the label is part of the
|
||||
aggregated row rather than something the pivot can re-derive. Editing a label
|
||||
afterwards is a `PATCH /api/log/:logid` and needs nothing regenerated, but a
|
||||
source registered before this change needs **Generate SQL** run once, so its
|
||||
stored load templates write `label` and `bucket` onto the log row at all.
|
||||
|
||||
### Forecast operations
|
||||
POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums.
|
||||
|
||||
|
||||
@ -8,9 +8,100 @@
|
||||
// Tokens baked in at generation time: column names, source schema.table
|
||||
// Tokens substituted at request time: {{fc_table}}, {{where_clause}}, {{exclude_clause}},
|
||||
// {{version_id}}, {{logid}}, {{pf_user}}, {{note}},
|
||||
// {{label}}, {{bucket}}, {{tag}},
|
||||
// {{params}}, {{slice}}, {{date_from}}, {{date_to}},
|
||||
// {{value_incr}}, {{units_incr}}, {{set_clause}}, {{scale_factor}}
|
||||
|
||||
// What the pivot shows for a row's segment and its bucket.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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".
|
||||
//
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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 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
|
||||
// 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`;
|
||||
|
||||
// wrap a column name in double quotes for safe use in SQL
|
||||
function q(name) { return `"${name}"`; }
|
||||
|
||||
@ -194,26 +285,16 @@ function generateSQL(source, colMeta) {
|
||||
return `
|
||||
SELECT
|
||||
${grainSelect('t.')}
|
||||
,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 'Forecast' 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
|
||||
,${SEGMENT_EXPR} AS pf_segment
|
||||
,${BUCKET_EXPR} AS pf_bucket
|
||||
,${NOTE_EXPR} AS pf_note
|
||||
,l.operation AS pf_op
|
||||
FROM {{fc_table}} t
|
||||
LEFT JOIN pf.log l
|
||||
ON l.id = t.pf_logid
|
||||
ON l.id = t.pf_logid${VERSION_JOIN}
|
||||
GROUP BY
|
||||
${grain.groupCols('t.').join('\n ,')}
|
||||
,l.operation
|
||||
,l.tag
|
||||
,l.note
|
||||
,l.bucket`.trim();
|
||||
,${LABEL_GROUP_COLS.join('\n ,')}`.trim();
|
||||
}
|
||||
|
||||
// grain columns + pf_gkey + summed measures, in the leading-comma style the
|
||||
@ -262,8 +343,9 @@ GROUP BY
|
||||
return `
|
||||
WITH
|
||||
ilog AS (
|
||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket, tag)
|
||||
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}',
|
||||
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''), NULLIF('{{tag}}', ''))
|
||||
RETURNING id
|
||||
)
|
||||
,ins AS (
|
||||
@ -282,8 +364,9 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||
return `
|
||||
WITH
|
||||
ilog AS (
|
||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||
VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}')
|
||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket, tag)
|
||||
VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}',
|
||||
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''), NULLIF('{{tag}}', ''))
|
||||
RETURNING id
|
||||
)
|
||||
,ins AS (
|
||||
@ -429,13 +512,53 @@ function applyTokens(sql, tokens) {
|
||||
|
||||
// build a SQL WHERE clause string from a slice object
|
||||
// only dimension columns are included; unrecognised keys are silently skipped
|
||||
function buildWhere(slice, dimCols) {
|
||||
// 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
|
||||
// exact: the name lives on the log row, and every forecast row carries the
|
||||
// pf_logid that points at it.
|
||||
//
|
||||
// Without this they were dropped from the slice, and clicking a single bucket's
|
||||
// cell scaled every bucket at that dimension intersection while the panel showed
|
||||
// only the one clicked.
|
||||
const COMPUTED_SLICE_COLS = { pf_segment: SEGMENT_EXPR, pf_bucket: BUCKET_EXPR };
|
||||
|
||||
function computedSlicePredicate(col, val, versionId) {
|
||||
if (versionId == null) {
|
||||
const err = new Error(`Cannot filter on ${col} without a version`);
|
||||
err.status = 500;
|
||||
throw err;
|
||||
}
|
||||
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}
|
||||
WHERE l.version_id = ${parseInt(versionId)}
|
||||
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals})
|
||||
)`;
|
||||
}
|
||||
|
||||
function buildWhere(slice, dimCols, versionId) {
|
||||
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
|
||||
|
||||
const allowed = new Set(dimCols);
|
||||
const parts = [];
|
||||
|
||||
for (const [col, val] of Object.entries(slice)) {
|
||||
if (COMPUTED_SLICE_COLS[col]) {
|
||||
parts.push(computedSlicePredicate(col, val, versionId));
|
||||
continue;
|
||||
}
|
||||
// A pf_ key this does not understand is refused rather than skipped.
|
||||
// Skipping is how a selection silently widened: the operation ran against
|
||||
// everything the dropped key would have excluded. pf_iter is the one
|
||||
// exception -- the client strips it deliberately, since two cells that
|
||||
// differ only by iter band are the same slice.
|
||||
if (col.startsWith('pf_') && col !== 'pf_iter') {
|
||||
const err = new Error(`Slice names ${col}, which cannot be filtered on`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!allowed.has(col)) continue;
|
||||
if (Array.isArray(val)) {
|
||||
const escaped = val.map(v => esc(v));
|
||||
@ -448,17 +571,73 @@ function buildWhere(slice, dimCols) {
|
||||
return parts.length ? parts.join('\nAND ') : 'TRUE';
|
||||
}
|
||||
|
||||
// The pivot's own filter, carried alongside the slices so an operation writes
|
||||
// exactly the rows the ledger counted.
|
||||
//
|
||||
// A slice is {col: value} and can only ever mean equality, so a view filtered to
|
||||
// sseas_e <= 2027 could not be expressed as one. Refusing it was safe but
|
||||
// useless -- a bounded season is an ordinary way to scope a forecast -- so the
|
||||
// operators travel as [col, op, value] triples instead.
|
||||
//
|
||||
// Perspective's operator names, not SQL's, since that is where these come from.
|
||||
// Anything outside this list is refused rather than ignored: a scope silently
|
||||
// dropped is a write that is wider than the panel that authorised it.
|
||||
const SCOPE_OPS = {
|
||||
'==': (c, v) => `${c} = ${v[0]}`,
|
||||
'!=': (c, v) => `${c} != ${v[0]}`,
|
||||
'>': (c, v) => `${c} > ${v[0]}`,
|
||||
'>=': (c, v) => `${c} >= ${v[0]}`,
|
||||
'<': (c, v) => `${c} < ${v[0]}`,
|
||||
'<=': (c, v) => `${c} <= ${v[0]}`,
|
||||
'in': (c, v) => `${c} IN (${v.join(', ')})`,
|
||||
'not in': (c, v) => `${c} NOT IN (${v.join(', ')})`,
|
||||
'is null': (c) => `${c} IS NULL`,
|
||||
'is not null': (c) => `${c} IS NOT NULL`,
|
||||
};
|
||||
|
||||
function buildScopeClause(scope, dimCols, versionId) {
|
||||
if (!Array.isArray(scope) || scope.length === 0) return '';
|
||||
const allowed = new Set(dimCols);
|
||||
const parts = scope.map((entry) => {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
const err = new Error(`Malformed scope entry ${JSON.stringify(entry)}`);
|
||||
err.status = 400; throw err;
|
||||
}
|
||||
const [col, op, ...rest] = entry;
|
||||
const vals = (Array.isArray(rest[0]) ? rest[0] : rest).filter(v => v !== undefined);
|
||||
|
||||
if (COMPUTED_SLICE_COLS[col]) {
|
||||
if (op !== '==' && op !== 'in') {
|
||||
const err = new Error(`${col} can only be scoped with == or in, not ${op}`);
|
||||
err.status = 400; throw err;
|
||||
}
|
||||
return computedSlicePredicate(col, vals, versionId);
|
||||
}
|
||||
if (!allowed.has(col)) {
|
||||
const err = new Error(`Column "${col}" is not available for filtering`);
|
||||
err.status = 400; throw err;
|
||||
}
|
||||
const fn = SCOPE_OPS[op];
|
||||
if (!fn) {
|
||||
const err = new Error(`Unsupported filter operator "${op}" on ${col}`);
|
||||
err.status = 400; throw err;
|
||||
}
|
||||
return fn(`"${col}"`, vals.map(v => `'${esc(String(v))}'`));
|
||||
});
|
||||
return parts.join('\nAND ');
|
||||
}
|
||||
|
||||
// build a WHERE clause spanning several slices — an OR of AND-groups.
|
||||
// A union of slices cannot be flattened into one IN list per column: slices
|
||||
// {Region:East, State:NY} and {Region:West, State:CA} would become
|
||||
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
|
||||
function buildWhereAny(slices, dimCols) {
|
||||
function buildWhereAny(slices, dimCols, versionId) {
|
||||
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
|
||||
if (list.length === 0) return 'TRUE';
|
||||
if (list.length === 1) return buildWhere(list[0], dimCols);
|
||||
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
|
||||
|
||||
const groups = list
|
||||
.map(s => buildWhere(s, dimCols))
|
||||
.map(s => buildWhere(s, dimCols, versionId))
|
||||
.filter(w => w !== 'TRUE');
|
||||
|
||||
// any slice that reduced to TRUE selects everything, so the union does too
|
||||
@ -544,4 +723,6 @@ function esc(val) {
|
||||
return String(val).replace(/'/g, "''");
|
||||
}
|
||||
|
||||
module.exports = { generateSQL, grainOf, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
||||
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause,
|
||||
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 };
|
||||
|
||||
@ -131,9 +131,12 @@ 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, bucket, seq } = req.body;
|
||||
if (note === undefined && tag === undefined && bucket === undefined && seq === undefined) {
|
||||
return res.status(400).json({ error: 'Nothing to update — send note, tag, bucket and/or seq' });
|
||||
const { note, tag, bucket, label } = req.body;
|
||||
if (note === undefined && tag === undefined
|
||||
&& bucket === undefined && label === undefined) {
|
||||
return res.status(400).json({
|
||||
error: 'Nothing to update — send note, tag, bucket and/or label'
|
||||
});
|
||||
}
|
||||
try {
|
||||
// COALESCE on the flag, not the value: an explicit null or '' must be
|
||||
@ -143,15 +146,14 @@ module.exports = function(pool) {
|
||||
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,
|
||||
seq = CASE WHEN $8::bool THEN $9::int ELSE seq END
|
||||
label = CASE WHEN $8::bool THEN $9::text ELSE label END
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
logId,
|
||||
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),
|
||||
seq !== undefined, (seq === undefined || seq === null || seq === '')
|
||||
? null : parseInt(seq),
|
||||
label !== undefined, label === undefined ? null : (String(label).trim() || null),
|
||||
]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc } = require('../lib/sql_generator');
|
||||
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
|
||||
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
|
||||
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
|
||||
const { sessionUser } = require('../lib/auth');
|
||||
const { fcTable } = require('../lib/utils');
|
||||
|
||||
@ -42,10 +44,16 @@ module.exports = function(pool) {
|
||||
// Only scale has a target to prorate towards; recode and clone rewrite rows
|
||||
// rather than distribute an amount, so for them this only decides whether the
|
||||
// work lands as one log entry or several.
|
||||
function sliceUnits(slices, ctx, applyMode) {
|
||||
// The scope is ANDed onto every unit rather than folded into the slices: it
|
||||
// applies to all of them equally, and under apply_mode 'each' folding it in
|
||||
// would repeat the same predicate in every statement for no gain.
|
||||
function sliceUnits(slices, ctx, applyMode, scope) {
|
||||
const vid = ctx.version.id;
|
||||
const scl = buildScopeClause(scope, ctx.filterCols, vid);
|
||||
const and = (w) => (scl ? (w === 'TRUE' ? scl : `${w}\nAND ${scl}`) : w);
|
||||
return applyMode === 'each'
|
||||
? slices.map(sl => ({ slices: [sl], where: buildWhere(sl, ctx.filterCols) }))
|
||||
: [{ slices, where: buildWhereAny(slices, ctx.filterCols) }];
|
||||
? slices.map(sl => ({ slices: [sl], where: and(buildWhere(sl, ctx.filterCols, vid)) }))
|
||||
: [{ slices, where: and(buildWhereAny(slices, ctx.filterCols, vid)) }];
|
||||
}
|
||||
|
||||
// The offset is interpolated into the statement as an interval literal, so a
|
||||
@ -71,7 +79,7 @@ module.exports = function(pool) {
|
||||
// otherwise reduce to TRUE and apply the operation to the whole version.
|
||||
// Refuse rather than let a malformed selection rewrite every row.
|
||||
function assertSelective(slices, ctx) {
|
||||
const allowed = new Set(ctx.filterCols);
|
||||
const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]);
|
||||
slices.forEach((sl, i) => {
|
||||
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
||||
if (hits.length === 0) {
|
||||
@ -88,7 +96,7 @@ module.exports = function(pool) {
|
||||
// echo back what the caller asked for, for the audit log
|
||||
function pickIntent(body) {
|
||||
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
|
||||
'target_value', 'target_units', 'target_price'];
|
||||
'target_value', 'target_units', 'target_price', 'scope'];
|
||||
const out = {};
|
||||
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
|
||||
return out;
|
||||
@ -334,24 +342,12 @@ module.exports = function(pool) {
|
||||
await client.query(`
|
||||
DECLARE pf_cur CURSOR FOR
|
||||
SELECT t.*
|
||||
,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 row counts towards. A load falls back to its own
|
||||
-- name until it is labelled; an adjustment falls back to
|
||||
-- 'Forecast', 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.
|
||||
,COALESCE(NULLIF(l.bucket, ''),
|
||||
CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
|
||||
ELSE 'Forecast' 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
|
||||
,${SEGMENT_EXPR} AS pf_segment
|
||||
,${BUCKET_EXPR} AS pf_bucket
|
||||
,${NOTE_EXPR} AS pf_note
|
||||
FROM ${tbl} t
|
||||
LEFT JOIN pf.log l
|
||||
ON l.id = t.pf_logid
|
||||
ON l.id = t.pf_logid${VERSION_JOIN}
|
||||
`);
|
||||
|
||||
// Accumulate into column arrays (not row objects) to avoid allocating one JS
|
||||
@ -420,7 +416,7 @@ module.exports = function(pool) {
|
||||
|
||||
// load baseline rows from source table — additive, no delete
|
||||
router.post('/versions/:id/baseline', async (req, res) => {
|
||||
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
||||
const pf_user = sessionUser(req);
|
||||
const dateOffset = date_offset || '0 days';
|
||||
if (!await assertInterval(dateOffset, res)) return;
|
||||
@ -438,6 +434,9 @@ module.exports = function(pool) {
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
label: esc(label || ''),
|
||||
bucket: esc(bucket || ''),
|
||||
tag: esc(tag || ''),
|
||||
params: esc(paramsJson),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
@ -457,7 +456,7 @@ module.exports = function(pool) {
|
||||
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
||||
const versionId = parseInt(req.params.id);
|
||||
const logid = parseInt(req.params.logid);
|
||||
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
||||
const pf_user = sessionUser(req);
|
||||
const dateOffset = date_offset || '0 days';
|
||||
if (!await assertInterval(dateOffset, res)) return;
|
||||
@ -496,11 +495,21 @@ module.exports = function(pool) {
|
||||
date_offset: dateOffset,
|
||||
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||
});
|
||||
// This route deletes the log row and inserts a fresh one, so every
|
||||
// annotation on it has to be handed back or it is lost. `??`, not `||`:
|
||||
// an empty string is the form clearing a field on purpose, undefined is
|
||||
// the form not carrying it at all -- the segment form has no tag input,
|
||||
// so tag is always the latter and must survive an edit made for any
|
||||
// other reason.
|
||||
const keep = (sent, prior) => esc(sent ?? prior ?? '');
|
||||
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),
|
||||
tag: keep(tag, oldLog.tag),
|
||||
params: esc(paramsJson),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
@ -565,7 +574,7 @@ module.exports = function(pool) {
|
||||
|
||||
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||
router.post('/versions/:id/reference', async (req, res) => {
|
||||
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
||||
const pf_user = sessionUser(req);
|
||||
const dateOffset = date_offset || '0 days';
|
||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
@ -582,6 +591,9 @@ module.exports = function(pool) {
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
label: esc(label || ''),
|
||||
bucket: esc(bucket || ''),
|
||||
tag: esc(tag || ''),
|
||||
params: esc(paramsJson),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
@ -617,7 +629,7 @@ module.exports = function(pool) {
|
||||
// sum() OVER () distribute the increment across the whole pool.
|
||||
// 'each' runs the same statement once per slice, so every slice
|
||||
// reaches the target on its own and gets its own log entry.
|
||||
const units = sliceUnits(slices, ctx, applyMode);
|
||||
const units = sliceUnits(slices, ctx, applyMode, req.body.scope);
|
||||
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
@ -666,7 +678,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_bucket: 'Forecast', pf_note: opLabel, pf_op: 'scale' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: ADJUSTMENT_SEGMENT, pf_bucket: ADJUSTMENT_BUCKET, pf_note: opLabel, pf_op: 'scale' }));
|
||||
res.json({
|
||||
rows,
|
||||
rows_affected: rows.length,
|
||||
@ -699,7 +711,7 @@ module.exports = function(pool) {
|
||||
|
||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||
const setClause = buildSetClause(ctx.dimCols, set);
|
||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate');
|
||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope);
|
||||
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
@ -726,7 +738,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_bucket: 'Forecast', pf_note: opLabel, pf_op: 'recode' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: ADJUSTMENT_SEGMENT, pf_bucket: ADJUSTMENT_BUCKET, 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 {}
|
||||
@ -788,7 +800,7 @@ module.exports = function(pool) {
|
||||
[cname, `${alias}."${periodCol}"`])
|
||||
);
|
||||
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
|
||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate');
|
||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope);
|
||||
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
@ -821,7 +833,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_bucket: 'Forecast', pf_note: opLabel, pf_op: 'clone' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: ADJUSTMENT_SEGMENT, pf_bucket: ADJUSTMENT_BUCKET, 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 {}
|
||||
|
||||
@ -307,19 +307,29 @@ ${colDefs},
|
||||
}
|
||||
});
|
||||
|
||||
// update version name, description, or exclude_iters
|
||||
// update version name, description, exclude_iters, or the fallback display
|
||||
// names.
|
||||
//
|
||||
// bucket_order is deliberately not settable: the bucket column order is the
|
||||
// text in pf.log.bucket now, so a stored order would be a second answer to
|
||||
// the same question, and a silent one -- nothing reads it.
|
||||
//
|
||||
// The three name columns are flag-and-value pairs rather than COALESCE:
|
||||
// clearing one back to the built-in means writing null, which COALESCE on
|
||||
// the value alone cannot tell from "not mentioned".
|
||||
router.put('/versions/:id', async (req, res) => {
|
||||
const { name, description, exclude_iters, bucket_order } = req.body;
|
||||
const { name, description, exclude_iters,
|
||||
adjustment_segment, adjustment_bucket, unlabeled_load } = req.body;
|
||||
const set = (v) => (v === undefined ? null : (String(v).trim() || null));
|
||||
try {
|
||||
// bucket_order is a flag-and-value pair rather than COALESCE: an empty
|
||||
// array is a meaningful value (no ordering), and COALESCE could not tell
|
||||
// it from "not mentioned".
|
||||
const result = await pool.query(`
|
||||
UPDATE pf.version SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
exclude_iters = COALESCE($4, exclude_iters),
|
||||
bucket_order = CASE WHEN $5::bool THEN $6::jsonb ELSE bucket_order END
|
||||
adjustment_segment = CASE WHEN $5::bool THEN $6::text ELSE adjustment_segment END,
|
||||
adjustment_bucket = CASE WHEN $7::bool THEN $8::text ELSE adjustment_bucket END,
|
||||
unlabeled_load = CASE WHEN $9::bool THEN $10::text ELSE unlabeled_load END
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
`, [
|
||||
@ -327,8 +337,9 @@ ${colDefs},
|
||||
name || null,
|
||||
description || null,
|
||||
exclude_iters ? JSON.stringify(exclude_iters) : null,
|
||||
bucket_order !== undefined,
|
||||
bucket_order === undefined ? null : JSON.stringify(bucket_order || [])
|
||||
adjustment_segment !== undefined, set(adjustment_segment),
|
||||
adjustment_bucket !== undefined, set(adjustment_bucket),
|
||||
unlabeled_load !== undefined, set(unlabeled_load)
|
||||
]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Version not found' });
|
||||
|
||||
@ -83,15 +83,20 @@ WHERE TRUE
|
||||
|
||||
-- Display order for the pivot's segment and bucket columns.
|
||||
--
|
||||
-- Perspective orders column groups by the value string, and SortDir's col asc /
|
||||
-- col desc only reverses that -- so no sort setting can produce
|
||||
-- Prior Year -> Plan -> Actual -> Forecast, which is alphabetical in neither
|
||||
-- direction. The order has to be carried in the value itself, as a "01 · " style
|
||||
-- prefix applied when the rows are served.
|
||||
-- The segment's display name in the pivot, falling back to tag then note.
|
||||
--
|
||||
-- bucket_order lives on the version rather than the source because the Baseline
|
||||
-- page, where it is maintained, is version-scoped. log.seq orders the segments
|
||||
-- within that.
|
||||
-- Separate from both because those have jobs already -- tag groups adjustments
|
||||
-- into initiatives for the bridge, note is free commentary -- and because the
|
||||
-- label carries the sort order. Perspective orders column groups by the value
|
||||
-- string, so a leading "01 - " is how ordering is expressed; putting that in the
|
||||
-- note would put it in every note.
|
||||
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS label text;
|
||||
|
||||
-- Vestigial, both of them. They held the ordinal when the "01 - " prefix was
|
||||
-- computed for the pivot rather than typed into label and bucket: bucket_order
|
||||
-- sequenced the bucket columns, log.seq the segments within them. Nothing reads
|
||||
-- either now, and nothing writes them -- kept only because dropping a column is
|
||||
-- not worth a migration to reclaim two that cost nothing.
|
||||
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb;
|
||||
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer;
|
||||
|
||||
@ -108,6 +113,15 @@ ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer;
|
||||
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;
|
||||
|
||||
-- The names a row falls back to when nobody has named it, per scenario. Null
|
||||
-- means "use the built-in", which is the DISPLAY DEFAULTS block in
|
||||
-- lib/sql_generator.js. Read through a join at query time, not baked into
|
||||
-- pf.sql: those templates are keyed on (source_id, operation) and shared by
|
||||
-- every version of a source.
|
||||
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;
|
||||
|
||||
-- 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
|
||||
|
||||
@ -106,7 +106,9 @@ export function buildSteps(rows, {
|
||||
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
||||
|
||||
const meta = logMeta[r.pf_logid] || {}
|
||||
const tag = (meta.tag || '').trim()
|
||||
// label first, the same precedence pf_segment uses, so the bridge and the
|
||||
// pivot call a step by the same name
|
||||
const tag = (meta.label || meta.tag || '').trim()
|
||||
const label = tag || (meta.note || '').trim() ||
|
||||
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
|
||||
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`
|
||||
@ -241,10 +243,9 @@ export default function BridgeView({
|
||||
}
|
||||
} else {
|
||||
let filter = []
|
||||
// Expression columns have to come with the filter that uses them. The
|
||||
// pivot's filter can name pf_bucket_ord or pf_segment_ord, which exist
|
||||
// only as expressions — a view built without them cannot resolve the
|
||||
// column and fails outright.
|
||||
// Expression columns have to come with the filter that uses them: a
|
||||
// filter can name a column that exists only as an expression, and a view
|
||||
// built without it cannot resolve the column and fails outright.
|
||||
let expressions = {}
|
||||
if (scope === 'filtered' && viewerRef?.current) {
|
||||
const cfg = await viewerRef.current.save()
|
||||
|
||||
@ -110,11 +110,22 @@ function Submit({ onClick, children, disabled }) {
|
||||
}
|
||||
|
||||
// ── 1. Selection ────────────────────────────────────────────────────────────
|
||||
function SelectionList({ slices, currentTotals, onRemove, onClear }) {
|
||||
function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClear }) {
|
||||
const multi = slices.length > 1
|
||||
const perSlice = currentTotals?.perSlice || []
|
||||
const valueCol = currentTotals?.valueCol
|
||||
|
||||
// The pivot's own filter. Shown because it scopes every figure below and
|
||||
// every row the operation writes, while appearing in none of the slices --
|
||||
// perspective-click reports only the cell's own dimensions, so without this
|
||||
// the panel prints a selection wider than the one it is acting on.
|
||||
const scopeLine = viewScope
|
||||
.map(([col, op, ...rest]) => {
|
||||
const vals = (Array.isArray(rest[0]) ? rest[0] : rest).filter(v => v !== undefined)
|
||||
return `${col} ${op}${vals.length ? ' ' + vals.join(', ') : ''}`
|
||||
})
|
||||
.join(' · ')
|
||||
|
||||
if (!slices.length) {
|
||||
return (
|
||||
<p className="text-gray-600 italic leading-relaxed">
|
||||
@ -126,6 +137,12 @@ function SelectionList({ slices, currentTotals, onRemove, onClear }) {
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{scopeLine && (
|
||||
<div className="mb-1 flex items-baseline gap-1.5 text-[11px]">
|
||||
<span className="text-gray-400 uppercase tracking-wide shrink-0">within</span>
|
||||
<span className="font-mono text-gray-600 truncate" title={scopeLine}>{scopeLine}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto max-h-36 -mx-1 px-1">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
@ -270,9 +287,16 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
const loose = []
|
||||
const byTag = new Map()
|
||||
for (const e of entries) {
|
||||
if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
|
||||
const meta = logMeta[e.logid] || {}
|
||||
const tag = (meta.tag || '').trim()
|
||||
// Named like every other line: the label the pivot shows, then the older
|
||||
// fallbacks. "Baseline" was hardcoded, so a segment called 03 - New Orders
|
||||
// everywhere else read as "Baseline" here alone.
|
||||
if (e.key === 'baseline') {
|
||||
const name = (meta.label || meta.tag || meta.note || '').trim()
|
||||
baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' })
|
||||
continue
|
||||
}
|
||||
const tag = (meta.label || meta.tag || '').trim()
|
||||
if (tag) {
|
||||
const g = byTag.get(tag) ||
|
||||
{ key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid }
|
||||
@ -285,7 +309,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
const op = meta.operation || e.iter || 'adjustment'
|
||||
loose.push({
|
||||
...e, kind: 'entry', count: 1,
|
||||
label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
|
||||
label: (meta.label || meta.note || '').trim()
|
||||
|| `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -299,7 +324,22 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
|
||||
const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
|
||||
const grand = { value: total.value + excl.value, units: total.units + excl.units }
|
||||
const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded'
|
||||
// Named by the segments themselves where we have them -- "02 - Prior Year"
|
||||
// reads as a thing a forecaster recognises, where "reference" names only the
|
||||
// iter band that happens to exclude it.
|
||||
const exclName = (currentTotals?.excluded?.names || []).join(' · ')
|
||||
|| (currentTotals?.excludedIters || []).join(' / ')
|
||||
|| 'excluded'
|
||||
|
||||
// Everything in the selection is immovable. Worth saying outright: the panel
|
||||
// otherwise prints a row of zeros and leaves the reason to be worked out.
|
||||
const nothingToAdjust = hasExcl && !total.value && !total.units
|
||||
|
||||
// One line per immovable segment. Falls back to the combined figure for a
|
||||
// selection whose rows carry no segment name.
|
||||
const exclLines = (currentTotals?.excluded?.bySegment?.length
|
||||
? currentTotals.excluded.bySegment
|
||||
: (hasExcl ? [{ name: exclName, ...excl }] : []))
|
||||
|
||||
// the basis decides which line the editable rows are measured from
|
||||
const basisOf = (key) => {
|
||||
@ -413,6 +453,29 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* What cannot move comes first: it is the constraint the rest is
|
||||
worked out against. Then the walk, which sums to Adjustable, and
|
||||
the two together make the selected total. */}
|
||||
{exclLines.map(seg => (
|
||||
<tr key={seg.name} className="text-amber-700">
|
||||
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={seg.name}>
|
||||
{seg.name}
|
||||
<span className="ml-1.5 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide">
|
||||
final
|
||||
</span>
|
||||
</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={`${numCell} text-amber-700`}>
|
||||
{m.key === 'price' ? fmtNum(priceOf(seg), m.dp) : fmtNum(seg[m.key], m.dp)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{exclLines.length > 0 && (
|
||||
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
|
||||
)}
|
||||
|
||||
{/* the walk from baseline to current, by initiative */}
|
||||
{lines.map(e => (
|
||||
<tr key={e.key} className="text-gray-600">
|
||||
@ -440,20 +503,6 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* Rows the pivot shows but operations cannot write. Listed so the
|
||||
panel's figures reconcile with what the grid displays. */}
|
||||
{hasExcl && (
|
||||
<tr className="text-gray-600">
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{exclName} <span className="text-gray-500">· fixed</span>
|
||||
</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={numCell}>
|
||||
{m.key === 'price' ? fmtNum(priceOf(excl), m.dp) : fmtNum(excl[m.key], m.dp)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{hasExcl && (
|
||||
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
|
||||
@ -468,6 +517,19 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Zeros in the Adjustable row are a true answer to the wrong question:
|
||||
they say how much can move, not why none of it can. Spell it out
|
||||
where the eye already is, rather than leaving the edit rows to fail
|
||||
silently below. */}
|
||||
{nothingToAdjust && (
|
||||
<tr>
|
||||
<td colSpan={measures.length + 1} className="pt-2 text-amber-700 leading-snug">
|
||||
Nothing in this selection can be adjusted — all of it is {exclName},
|
||||
loaded as {(currentTotals?.excludedIters || []).join(' / ') || 'reference'}.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
|
||||
|
||||
{/* the edit — three equivalent ways to say the same thing */}
|
||||
@ -738,6 +800,7 @@ function RequestPreview({ payload }) {
|
||||
export default function OperationPanel({
|
||||
dock,
|
||||
slices, setSlices, distinctSlices,
|
||||
viewScope = [],
|
||||
applyMode, setApplyMode,
|
||||
currentTotals,
|
||||
activeOp, setActiveOp,
|
||||
@ -780,6 +843,7 @@ export default function OperationPanel({
|
||||
)}
|
||||
<SelectionList
|
||||
slices={slices}
|
||||
viewScope={viewScope}
|
||||
currentTotals={currentTotals}
|
||||
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
|
||||
onClear={() => setSlices([])}
|
||||
|
||||
@ -97,12 +97,15 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
|
||||
// segment form
|
||||
const [segType, setSegType] = useState('baseline')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
|
||||
const [useRaw, setUseRaw] = useState(false)
|
||||
const [rawSql, setRawSql] = useState('')
|
||||
const [offset, setOffset] = useState('0 days')
|
||||
const [segNote, setSegNote] = useState('')
|
||||
// Presentation, not definition: what the segment counts toward and how it is
|
||||
// labelled in the pivot. Safe to set at any time, unlike its filters.
|
||||
const [segBucket, setSegBucket] = useState('')
|
||||
const [segLabel, setSegLabel] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingLogId, setEditingLogId] = useState(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@ -133,72 +136,56 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
// locally while typing so the field does not fight the fetched value, and
|
||||
// written on blur.
|
||||
const [buckets, setBuckets] = useState({})
|
||||
// seq orders the segment columns; bucket_order orders the bucket columns. Both
|
||||
// are carried into the pivot as a "01 · " prefix, because Perspective orders
|
||||
// column groups by the value string and no sort setting can express an
|
||||
// arbitrary order.
|
||||
const [seqs, setSeqs] = useState({})
|
||||
const [bucketOrder, setBucketOrder] = useState([])
|
||||
// Buckets already in use on this version, for the datalist. There is no stored
|
||||
// bucket order any more: the order is whatever the typed text sorts as, so a
|
||||
// bucket is named "02 - Forecast" and that is the whole mechanism.
|
||||
const [bucketsInUse, setBucketsInUse] = useState([])
|
||||
|
||||
async function saveBucket(entry, value) {
|
||||
// Label and bucket are presentation, not definition: they change what the pivot
|
||||
// shows and what the segment counts toward, never which rows were loaded. So
|
||||
// they stay editable after adjustments exist, unlike the filters and offset,
|
||||
// where an edit would silently recalibrate scales sized against the old rows.
|
||||
//
|
||||
// Both are only read at load time, hence the reload in the confirmation: the
|
||||
// label is part of the aggregated row the pivot holds, not something it can
|
||||
// re-derive in place.
|
||||
async function saveLogField(entry, field, value) {
|
||||
const next = value.trim()
|
||||
if (next === (entry.bucket || '')) return
|
||||
if (next === (entry[field] || '')) return
|
||||
try {
|
||||
const res = await fetch(`/api/log/${entry.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: next }),
|
||||
body: JSON.stringify({ [field]: next }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
loadLog()
|
||||
flash(next ? `Counts toward ${next} — reload the Forecast view to see it` : 'Banner cleared')
|
||||
} catch (err) {
|
||||
flash(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeq(entry, value) {
|
||||
const raw = String(value).trim()
|
||||
const next = raw === '' ? null : parseInt(raw)
|
||||
if (raw !== '' && !Number.isFinite(next)) { flash('Sequence must be a number', 'error'); return }
|
||||
if (next === (entry.seq ?? null)) return
|
||||
try {
|
||||
const res = await fetch(`/api/log/${entry.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq: next }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
loadLog()
|
||||
flash('Order saved')
|
||||
flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared')
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
}
|
||||
|
||||
async function saveBucketOrder(next) {
|
||||
setBucketOrder(next)
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket_order: next }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
flash('Bucket order saved')
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
// Column widths read off the content rather than guessed. ch is the width of a
|
||||
// '0', so for proportional text it runs slightly generous -- which is what is
|
||||
// wanted for an input you are about to type a longer name into. The floors keep
|
||||
// an empty table from collapsing its headers; the ceilings keep one long note
|
||||
// from pushing the numbers off the side.
|
||||
function widthCh(values, min, max) {
|
||||
const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0)
|
||||
return `${Math.min(max, Math.max(min, longest + 2))}ch`
|
||||
}
|
||||
const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 34)
|
||||
const bucketW = widthCh(log.map(e => e.bucket), 16, 24)
|
||||
const noteW = widthCh(log.map(e => e.note), 22, 44)
|
||||
|
||||
function loadLog() {
|
||||
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
||||
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
||||
setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation)))
|
||||
|
||||
// The stored order first, then any bucket actually in use that it does not
|
||||
// name — so labelling a new segment makes its bucket appear at the end,
|
||||
// ready to be moved, rather than silently missing from the list.
|
||||
const inUse = [...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))]
|
||||
const stored = versions.find(v => String(v.id) === String(versionId))?.bucket_order
|
||||
const ordered = (Array.isArray(stored) ? stored : []).filter(b => b)
|
||||
setBucketOrder([...ordered, ...inUse.filter(b => !ordered.includes(b))])
|
||||
// Every bucket in use, adjustments included: typing one on a new segment
|
||||
// should offer the ones already there rather than inviting a near-miss
|
||||
// spelling, which would silently split the column in two.
|
||||
setBucketsInUse([...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))].sort())
|
||||
})
|
||||
}
|
||||
|
||||
@ -234,8 +221,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const endpoint = isRef ? 'reference' : 'baseline'
|
||||
const body = {
|
||||
where_clause: clause,
|
||||
note: description || segNote,
|
||||
note: segNote,
|
||||
date_offset: offsetStr,
|
||||
label: segLabel.trim(),
|
||||
bucket: segBucket.trim(),
|
||||
...(useRaw ? { raw_where: clause } : { filters }),
|
||||
}
|
||||
setSubmitting(true)
|
||||
@ -271,7 +260,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const params = entry.params || {}
|
||||
setSegType(entry.operation)
|
||||
setSegNote(entry.note || '')
|
||||
setDescription('')
|
||||
setSegLabel(entry.label || '')
|
||||
setSegBucket(entry.bucket || '')
|
||||
setOffset(params.date_offset || '0 days')
|
||||
const groups = normalizeFilters(params.filters)
|
||||
if (groups) {
|
||||
@ -298,8 +288,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
function cancelEdit() {
|
||||
setEditingLogId(null)
|
||||
setShowAddForm(false)
|
||||
setDescription('')
|
||||
setSegNote('')
|
||||
setSegLabel('')
|
||||
setSegBucket('')
|
||||
setOffsetYr(0)
|
||||
setOffsetMo(0)
|
||||
setUseRaw(false)
|
||||
@ -356,11 +347,33 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
setTimeout(() => setMsg(null), 3000)
|
||||
}
|
||||
|
||||
// The names a row falls back to when nobody has named it. Blank means "use the
|
||||
// built-in", so these save on blur like the segment fields and an empty box is
|
||||
// a meaningful value rather than a missing one.
|
||||
async function saveVersionName(field, value) {
|
||||
const next = value.trim()
|
||||
if (next === (selectedVersion?.[field] || '')) return
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: next }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
await refreshVersions(sourceId)
|
||||
flash('Saved — reload the Forecast view to see it')
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
}
|
||||
|
||||
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-gray-50">
|
||||
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
||||
{/* No page-wide cap and no stretching: at max-w-4xl (896px) the eleven-column
|
||||
segment table always had something smashed, and uncapped it ran to the
|
||||
window. items-start makes each block as wide as its own content needs,
|
||||
which for the table is the measured column widths below. */}
|
||||
<div className="p-4 flex flex-col gap-4 items-start max-w-full">
|
||||
|
||||
{msg && (
|
||||
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
@ -384,6 +397,31 @@ 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. */}
|
||||
{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>
|
||||
{[
|
||||
['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>
|
||||
<input
|
||||
key={`${field}-${versionId}-${selectedVersion?.[field] || ''}`}
|
||||
defaultValue={selectedVersion?.[field] || ''}
|
||||
onBlur={e => saveVersionName(field, e.target.value)}
|
||||
placeholder={builtin}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewVersion && (
|
||||
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||
<div className="flex items-end gap-3">
|
||||
@ -409,61 +447,27 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
{versionId && <>
|
||||
|
||||
{/* Segments loaded */}
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="bg-white border border-gray-200 rounded max-w-full">
|
||||
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||
<span>Segments loaded</span>
|
||||
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||
</div>
|
||||
{/* Bucket column order. Up/down rather than drag: the list is four or
|
||||
five items that change once a quarter, and a keyboard-reachable
|
||||
pair of buttons beats a drag target nobody can hit on a laptop
|
||||
trackpad. */}
|
||||
{bucketOrder.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap px-3 py-2 border-b border-gray-100">
|
||||
<span className="text-gray-500 text-xs whitespace-nowrap">column order</span>
|
||||
{bucketOrder.map((b, i) => (
|
||||
<span key={b}
|
||||
className="inline-flex items-center gap-1 border border-gray-200 rounded
|
||||
pl-2 pr-1 py-0.5 text-xs bg-white">
|
||||
<span className="text-gray-400 tabular-nums">{String(i + 1).padStart(2, '0')} -</span>
|
||||
<span className="text-gray-700">{b}</span>
|
||||
<button
|
||||
disabled={i === 0}
|
||||
onClick={() => {
|
||||
const next = [...bucketOrder]
|
||||
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
|
||||
saveBucketOrder(next)
|
||||
}}
|
||||
title="Move earlier"
|
||||
className="text-gray-400 hover:text-blue-600 disabled:opacity-25 px-0.5 leading-none">◀</button>
|
||||
<button
|
||||
disabled={i === bucketOrder.length - 1}
|
||||
onClick={() => {
|
||||
const next = [...bucketOrder]
|
||||
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
|
||||
saveBucketOrder(next)
|
||||
}}
|
||||
title="Move later"
|
||||
className="text-gray-400 hover:text-blue-600 disabled:opacity-25 px-0.5 leading-none">▶</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<datalist id="pf-bucket-options">
|
||||
<option value="Forecast" />
|
||||
<option value="Prior Year" />
|
||||
<option value="Prior Prior Year" />
|
||||
<option value="Plan" />
|
||||
{[...new Set([...bucketsInUse,
|
||||
'01 - Prior Prior Year', '02 - Prior Year',
|
||||
'03 - Plan', '04 - Forecast'])].map(b => (
|
||||
<option key={b} value={b} />
|
||||
))}
|
||||
</datalist>
|
||||
<table className="w-full text-xs">
|
||||
<table className="text-xs">
|
||||
<thead className="bg-gray-50">
|
||||
<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">#</th>
|
||||
<th className="px-3 py-1.5 font-medium w-14 text-right">seq</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 w-20">kind</th>
|
||||
<th className="px-3 py-1.5 font-medium" style={{ width: labelW }}>label</th>
|
||||
<th className="px-3 py-1.5 font-medium" style={{ width: noteW }}>note</th>
|
||||
<th className="px-3 py-1.5 font-medium" style={{ width: bucketW }}>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">{log[0]?.value_col || 'value'}</th>
|
||||
<th className="px-3 py-1.5 font-medium">by</th>
|
||||
@ -473,11 +477,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.length === 0 && (
|
||||
<tr><td colSpan={10} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||
<tr><td colSpan={11} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||
)}
|
||||
{!showAddForm && !editingLogId && (
|
||||
<tr className="border-t border-gray-100">
|
||||
<td colSpan={10} className="p-0">
|
||||
<td colSpan={11} className="p-0">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
|
||||
@ -499,28 +503,42 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
>
|
||||
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
|
||||
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
||||
<td className="px-3 py-2 text-right" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
value={seqs[entry.id] ?? (entry.seq ?? '')}
|
||||
onChange={e => setSeqs(v => ({ ...v, [entry.id]: e.target.value }))}
|
||||
onBlur={e => saveSeq(entry, e.target.value)}
|
||||
placeholder="—"
|
||||
className="w-10 text-right border border-transparent hover:border-gray-200
|
||||
focus:border-blue-400 rounded px-1 py-0.5 text-xs
|
||||
focus:outline-none bg-transparent tabular-nums" />
|
||||
</td>
|
||||
{/* The operation badge gets its own column. Sharing one with
|
||||
the note put "reference" hard against "YTD Sales" as soon
|
||||
as the note column lost width to label and bucket. */}
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||
{entry.operation}
|
||||
</span>
|
||||
{entry.note || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
defaultValue={entry.label || ''}
|
||||
key={`label-${entry.id}-${entry.label || ''}`}
|
||||
onBlur={e => saveLogField(entry, 'label', e.target.value)}
|
||||
placeholder={entry.tag || entry.note || '—'}
|
||||
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>
|
||||
{/* One line, clipped against the measured width above. The
|
||||
note is provenance and can run long, so left to itself it
|
||||
wrapped and pushed every row to two or three lines.
|
||||
Expanding the row shows it in full. The cap is on the div,
|
||||
not the cell: a max-width on a cell in an auto-layout table
|
||||
is only a hint, and the column can still collapse to
|
||||
min-content or grow past it. */}
|
||||
<td className="px-3 py-2">
|
||||
{entry.note
|
||||
? <div className="truncate" style={{ maxWidth: noteW }} title={entry.note}>{entry.note}</div>
|
||||
: <span className="text-gray-300">—</span>}
|
||||
</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)}
|
||||
onBlur={e => saveLogField(entry, 'bucket', 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" />
|
||||
@ -542,7 +560,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
||||
<td colSpan={8} className="px-2 py-2">
|
||||
<td colSpan={9} className="px-2 py-2">
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<SegmentForm mode="view" {...view} filterCols={filterCols} />
|
||||
</div>
|
||||
@ -581,8 +599,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
filters={filters} setFilters={setFilters}
|
||||
useRaw={useRaw} setUseRaw={setUseRaw}
|
||||
rawSql={rawSql} setRawSql={setRawSql}
|
||||
description={description} setDescription={setDescription}
|
||||
segNote={segNote} setSegNote={setSegNote}
|
||||
segBucket={segBucket} setSegBucket={setSegBucket}
|
||||
segLabel={segLabel} setSegLabel={setSegLabel}
|
||||
offset={offset} setOffset={setOffset}
|
||||
filterCols={filterCols}
|
||||
onSubmit={loadSegment}
|
||||
@ -608,8 +627,9 @@ function segmentValuesFor(entry, filterCols) {
|
||||
filters: groups || (filterCols.length > 0 ? [emptyGroup(filterCols)] : []),
|
||||
useRaw: !groups && !!params.where_clause,
|
||||
rawSql: params.where_clause || '',
|
||||
description: '',
|
||||
segNote: entry.note || '',
|
||||
segBucket: entry.bucket || '',
|
||||
segLabel: entry.label || '',
|
||||
offset: params.date_offset || '0 days',
|
||||
}
|
||||
}
|
||||
@ -620,8 +640,9 @@ function SegmentForm({
|
||||
filters, setFilters,
|
||||
useRaw, setUseRaw,
|
||||
rawSql, setRawSql,
|
||||
description, setDescription,
|
||||
segNote, setSegNote,
|
||||
segBucket, setSegBucket,
|
||||
segLabel, setSegLabel,
|
||||
offset, setOffset,
|
||||
filterCols,
|
||||
onSubmit,
|
||||
@ -692,14 +713,6 @@ function SegmentForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description (edit only) */}
|
||||
{mode === 'edit' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@ -830,8 +843,24 @@ function SegmentForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note + submit */}
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Label, bucket, note + submit.
|
||||
Label and bucket are presentation: the label is what the pivot shows for
|
||||
this segment, the bucket is what it counts toward. Both are free text and
|
||||
both sort by what is typed, so a leading "01 - " is how ordering is set —
|
||||
which is why they belong here, at the point the segment is defined, as
|
||||
well as being editable in the list afterwards. */}
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<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`} />
|
||||
</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"
|
||||
className={`${baseInp} text-sm py-1.5`} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||
<label className="text-xs text-gray-500">Note</label>
|
||||
<input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
|
||||
|
||||
@ -12,13 +12,24 @@ import '@perspective-dev/viewer/inline'
|
||||
import '@perspective-dev/viewer-datagrid'
|
||||
import '@perspective-dev/viewer/themes'
|
||||
|
||||
// Slice keys that are not col_meta columns: computed from pf.log when the rows are
|
||||
// served, so they are real columns in the loaded table but have to be resolved back
|
||||
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
|
||||
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
|
||||
|
||||
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
||||
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
|
||||
|
||||
function cleanLayout(cfg, validCols) {
|
||||
if (!cfg) return cfg
|
||||
const c = { ...cfg }
|
||||
const exprNames = new Set(Object.keys(cfg.expressions || {}))
|
||||
// Dead expressions go before the axis filter, not after: dropping them from
|
||||
// `expressions` is what makes `ok()` reject them everywhere else.
|
||||
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
||||
c.expressions = { ...c.expressions }
|
||||
for (const name of DEAD_ORDER_EXPRS) delete c.expressions[name]
|
||||
}
|
||||
const exprNames = new Set(Object.keys(c.expressions || {}))
|
||||
const ok = (col) => validCols.has(col) || exprNames.has(col)
|
||||
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
|
||||
if (c.group_by) c.group_by = c.group_by.filter(ok)
|
||||
@ -30,111 +41,40 @@ function cleanLayout(cfg, validCols) {
|
||||
return c
|
||||
}
|
||||
|
||||
// Ordering for the pf_bucket and pf_segment column groups, as Perspective
|
||||
// expression columns.
|
||||
// Expression columns this view used to manage, back when the pf_bucket and
|
||||
// pf_segment ordering prefix was computed in the pivot rather than stored in
|
||||
// pf.log.label.
|
||||
//
|
||||
// Perspective orders column groups by the value string, and SortDir's `col asc` /
|
||||
// `col desc` only reverses that — so Prior Year → Plan → Actual → Forecast is
|
||||
// expressible as neither, being alphabetical in neither direction. The order has
|
||||
// to be part of the value, as a "01 · " prefix.
|
||||
//
|
||||
// Done as expressions rather than in SQL. The prefix is a pivot-ordering concern,
|
||||
// and computing it server-side put it in every other reader too: the change log
|
||||
// and the bridge's basis list would both show "04 · Forecast". As expressions it
|
||||
// stays in the pivot, lives in ViewConfig so it travels with saved layouts, and
|
||||
// reordering takes effect immediately instead of needing a reload.
|
||||
//
|
||||
// Syntax per rust/perspective-client/src/rust/config/expressions.rs: columns in
|
||||
// double quotes, string literals in single, if/else with braces. The ordered
|
||||
// label is emitted as a literal rather than built with concat() — fewer moving
|
||||
// parts, and the mapping is known here anyway.
|
||||
// pf_ prefixed like every other column the server synthesises, so they sort
|
||||
// beside pf_bucket and pf_segment in the column list and cannot be mistaken for
|
||||
// source data. "Bucket" and "Segment" were the first choice and are too close to
|
||||
// the real thing -- segment_new is an actual column on this source -- and an
|
||||
// expression named after an existing column would shadow or reject rather than
|
||||
// merely confuse.
|
||||
const ORDER_EXPR_NAMES = { bucket: 'pf_bucket_ord', segment: 'pf_segment_ord' }
|
||||
|
||||
// Removed from any config that still carries them. They are no longer managed
|
||||
// names, so without this they would sit in saved layouts forever, ordering by a
|
||||
// rule nothing updates.
|
||||
const LEGACY_ORDER_EXPR_NAMES = ['Bucket', 'Segment']
|
||||
|
||||
// ASCII only, and " - " specifically because the source data already reads
|
||||
// "07 - Dec". A middle dot was the first choice and ExprTK rejects it: its string
|
||||
// scanner tests each *byte* with
|
||||
//
|
||||
// is_valid_string_char(c) = isprint((unsigned char) c) || is_whitespace(c)
|
||||
//
|
||||
// and "·" is U+00B7, two bytes 0xC2 0xB7 in UTF-8, of which isprint(0xC2) is
|
||||
// false in the C locale. The literal fails at that byte and the parser reports
|
||||
// from the start of it — "Invalid string token: 01".
|
||||
const ORDER_SEP = ' - '
|
||||
|
||||
function orderedLabel(ord, label) {
|
||||
return `${String(ord).padStart(2, '0')}${ORDER_SEP}${label}`
|
||||
}
|
||||
|
||||
// Same byte rule applies to the label being matched, so a bucket named with
|
||||
// anything outside printable ASCII cannot appear in an expression at all. Such a
|
||||
// label is left unordered rather than emitted into an expression that will not
|
||||
// parse and take the whole column with it.
|
||||
function isExprSafe(v) {
|
||||
return /^[\x20-\x7e]*$/.test(String(v))
|
||||
}
|
||||
|
||||
function sqlSafe(v) {
|
||||
return String(v).replace(/'/g, "''")
|
||||
}
|
||||
|
||||
// Anything unlisted falls through to the raw column.\n// pairs: [[rawValue, ordinal], ...] or [[rawValue, ordinal, displayAs], ...] where
|
||||
// the third element renames the value as well as ordering it.
|
||||
function buildOrderExpression(sourceCol, pairs, extra = []) {
|
||||
const cases = [...pairs, ...extra]
|
||||
.filter(([label, ord]) => label && ord > 0 && isExprSafe(label))
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(([label, ord, displayAs]) =>
|
||||
`if ("${sourceCol}" == '${sqlSafe(label)}') { '${sqlSafe(orderedLabel(ord, displayAs || label))}' }`)
|
||||
if (cases.length === 0) return null
|
||||
return `${cases.join('\nelse ')}\nelse { "${sourceCol}" }`
|
||||
}
|
||||
|
||||
// The server's synthetic segment labels. Unordered they sort *first*, not last:
|
||||
// '(' is 0x28 and digits begin at 0x30, so '(adjustment)' precedes '01 - ...'.
|
||||
// They get high ordinals instead, and lose the parentheses — those marked the
|
||||
// value as not-a-real-segment, which the ordinal now does.
|
||||
const SYNTHETIC_SEGMENTS = [
|
||||
['(unlabeled load)', 98, 'Unlabeled'],
|
||||
['(adjustment)', 99, 'Adjustments'],
|
||||
]
|
||||
|
||||
// Build both expressions from the version's bucket_order and the log's seq values.
|
||||
function buildOrderExpressions(bucketOrder, logMeta) {
|
||||
const out = {}
|
||||
|
||||
const buckets = (Array.isArray(bucketOrder) ? bucketOrder : [])
|
||||
.map((label, i) => [label, i + 1])
|
||||
const bucketExpr = buildOrderExpression('pf_bucket', buckets)
|
||||
if (bucketExpr) out[ORDER_EXPR_NAMES.bucket] = bucketExpr
|
||||
|
||||
// Segments are ordered by pf.log.seq, keyed on the label the pivot shows —
|
||||
// which is the tag if there is one, else the note, the same precedence the
|
||||
// server uses to build pf_segment.
|
||||
const segs = Object.values(logMeta || {})
|
||||
.filter(m => m.seq != null && ['baseline', 'reference'].includes(m.operation))
|
||||
.map(m => [(m.tag || m.note || '').trim(), m.seq])
|
||||
const segExpr = buildOrderExpression('pf_segment', segs, SYNTHETIC_SEGMENTS)
|
||||
if (segExpr) out[ORDER_EXPR_NAMES.segment] = segExpr
|
||||
|
||||
return out
|
||||
}
|
||||
// Stripped by cleanLayout, never created: a layout saved under that scheme still
|
||||
// names them, and without this they would sit there forever, ordering by a rule
|
||||
// nothing updates. They have to leave the axes at the same time as the
|
||||
// expressions themselves -- restore() rejects a config whose group_by or sort
|
||||
// names a column that no longer exists.
|
||||
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
|
||||
|
||||
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
|
||||
const { dark } = useTheme()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [largeDataset, setLargeDataset] = useState(false)
|
||||
// The pivot's own filter, refreshed whenever the ledger recomputes. A ref
|
||||
// rather than state: it is read at dispatch and at totals time, never
|
||||
// rendered from directly, and making it state would re-run the totals effect
|
||||
// that sets it.
|
||||
const viewFilterRef = useRef([])
|
||||
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
|
||||
// Rows the load is waiting on, so the wait can say what it is waiting for --
|
||||
// on this data the row count is the wait (see CLAUDE.md, "Load time is
|
||||
// dominated by row count").
|
||||
//
|
||||
// Filled twice. X-Row-Count is exact but arrives with the response headers,
|
||||
// and in grain mode the server aggregates before sending any: the number
|
||||
// turned up just as the wait ended. So the forecast table's own count goes in
|
||||
// first, from the same table-info the status bar reads, and the exact figure
|
||||
// replaces it when the headers land.
|
||||
const [loadRows, setLoadRows] = useState(null)
|
||||
// the same filter, for display: the panel has to show the scope it is acting
|
||||
// inside or the slice it prints is not the slice that gets written
|
||||
const [viewScope, setViewScope] = useState([])
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
// layouts
|
||||
@ -401,13 +341,23 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
for (let c = c0; c < c1; c++) {
|
||||
const key = userKeys[c]
|
||||
if (!key) continue
|
||||
// A column name is dimension values joined by | with the measure last, and
|
||||
// only as many values as the axis currently shows -- collapse the column
|
||||
// hierarchy and the deeper levels are simply absent. Mapping split_by
|
||||
// positionally over every segment therefore read the measure as a value
|
||||
// for the first collapsed dimension: a bucket subtotal came back as
|
||||
// smon_e = 'sales_usd', which matches no row, so the operation silently
|
||||
// had nothing to act on. Drop the measure, then map over what is left.
|
||||
const segs = key.split('|').slice(0, -1)
|
||||
const colFilters = splitBy
|
||||
.slice(0, segs.length)
|
||||
.map((col, ix) => {
|
||||
const v = key.split('|')[ix]
|
||||
const v = segs[ix]
|
||||
return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters])
|
||||
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
||||
(cfg.columns || []).filter(Boolean))
|
||||
if (!Object.keys(slice).length) continue
|
||||
const y = win.start_row + i
|
||||
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
||||
@ -487,14 +437,54 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
|
||||
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
|
||||
|
||||
// A slice carries every value as a string -- it is built from filters the
|
||||
// grid reports and from a payload that has to survive JSON. Perspective
|
||||
// matches on type, so a string '2027' against an integer column does not
|
||||
// filter to nothing, it is dropped: the ledger then totalled rows the pivot
|
||||
// was hiding, which is how a season filter on sseas_e went unnoticed while
|
||||
// the numbers disagreed by exactly the out-of-season rows.
|
||||
// The pivot's own filter is not part of a clicked slice -- perspective-click
|
||||
// reports only the cell's own dimensions -- so the ledger has to read it off
|
||||
// the viewer and apply it alongside. Without this the ledger totals rows the
|
||||
// grid is hiding, and the operation writes them: a grid scoped to
|
||||
// sseas_e = 2027 gave a cell of 921,225.71 against a ledger of 956,485.13.
|
||||
//
|
||||
// Taken from viewer.save(), so the values are already in the table's own
|
||||
// types and the operators are whatever the user set -- ranges and in-lists
|
||||
// included, which a slice cannot express.
|
||||
const viewFilter = await (async () => {
|
||||
try {
|
||||
const cfg = await viewerRef.current?.save()
|
||||
return (cfg?.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
|
||||
} catch { return [] }
|
||||
})()
|
||||
viewFilterRef.current = viewFilter
|
||||
setViewScope(viewFilter)
|
||||
|
||||
const schema = await tableRef.current.schema()
|
||||
const typed = (col, val) => {
|
||||
switch (schema[col]) {
|
||||
case 'integer': case 'float': return Number(val)
|
||||
case 'boolean': return val === true || val === 'true'
|
||||
case 'date': case 'datetime': return Number(val)
|
||||
default: return String(val)
|
||||
}
|
||||
}
|
||||
|
||||
async function totalsFor(sliceObj) {
|
||||
// pf_segment and pf_bucket are computed server-side but are ordinary
|
||||
// columns in the loaded table, so here they filter directly. They have to
|
||||
// be applied, or the ledger totals a wider selection than the operation
|
||||
// will write.
|
||||
const filters = [
|
||||
...viewFilter,
|
||||
...Object.entries(sliceObj)
|
||||
.filter(([col]) => dimNames.has(col))
|
||||
.map(([col, val]) => [col, '==', val]),
|
||||
...Object.entries(sliceObj)
|
||||
.filter(([col]) => dateNames.has(col))
|
||||
.map(([col, val]) => [col, '==', Number(val)]),
|
||||
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|
||||
|| schema[col] !== undefined)
|
||||
// a cell inside the filtered view cannot contradict it, so a repeated
|
||||
// column is the same predicate twice and harmless
|
||||
.filter(([col]) => !viewFilter.some(f => f[0] === col))
|
||||
.map(([col, val]) => [col, '==', typed(col, val)]),
|
||||
]
|
||||
const view = await tableRef.current.view({ filter: filters })
|
||||
const rows = await view.to_json()
|
||||
@ -507,7 +497,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
// rows the pivot shows but operations cannot write (usually 'reference').
|
||||
// Kept separate rather than filtered away: the grid total includes them,
|
||||
// so the panel has to account for them or the two disagree.
|
||||
const excluded = { value: 0, units: 0, rows: 0 }
|
||||
// Per segment, not one lump: YTD Sales and Open Orders are different
|
||||
// things, and a single "final" line hides which part of the number is
|
||||
// which.
|
||||
const excluded = { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() }
|
||||
for (const r of rows) {
|
||||
const k = r.pf_iter || '?'
|
||||
const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0
|
||||
@ -517,6 +510,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
excluded.value += val
|
||||
excluded.units += uni
|
||||
excluded.rows += 1
|
||||
// Name them by what they are, not by the iter band that happens to
|
||||
// exclude them: "02 - Prior Year" means something to a forecaster,
|
||||
// "reference" is the mechanism.
|
||||
const name = String(r.pf_segment || 'excluded')
|
||||
excluded.names.add(name)
|
||||
const seg = excluded.bySegment.get(name) || { name, value: 0, units: 0, rows: 0 }
|
||||
seg.value += val
|
||||
seg.units += uni
|
||||
seg.rows += 1
|
||||
excluded.bySegment.set(name, seg)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -588,10 +591,25 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
value: acc.value + (ps.excluded?.value || 0),
|
||||
units: acc.units + (ps.excluded?.units || 0),
|
||||
rows: acc.rows + (ps.excluded?.rows || 0),
|
||||
}), { value: 0, units: 0, rows: 0 })
|
||||
names: new Set([...acc.names, ...(ps.excluded?.names || [])]),
|
||||
bySegment: (() => {
|
||||
const m = acc.bySegment
|
||||
for (const seg of (ps.excluded?.bySegment?.values?.() || [])) {
|
||||
const t = m.get(seg.name) || { name: seg.name, value: 0, units: 0, rows: 0 }
|
||||
t.value += seg.value; t.units += seg.units; t.rows += seg.rows
|
||||
m.set(seg.name, t)
|
||||
}
|
||||
return m
|
||||
})(),
|
||||
}), { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() })
|
||||
|
||||
setCurrentTotals({
|
||||
byIter, byEntry, total, excluded, valueCol, unitsCol, perSlice,
|
||||
byIter, byEntry, total, valueCol, unitsCol, perSlice,
|
||||
excluded: {
|
||||
...excluded,
|
||||
names: [...excluded.names].sort(),
|
||||
bySegment: [...excluded.bySegment.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
},
|
||||
excludedIters: [...excludeIters],
|
||||
})
|
||||
} catch {
|
||||
@ -607,8 +625,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
|
||||
const map = {}
|
||||
for (const e of entries) map[e.id] = {
|
||||
label: e.label || null,
|
||||
tag: e.tag || null, note: e.note || null, operation: e.operation,
|
||||
bucket: e.bucket || null, seq: e.seq ?? null,
|
||||
bucket: e.bucket || null,
|
||||
}
|
||||
setLogMeta(map)
|
||||
} catch { setLogMeta({}) }
|
||||
@ -653,11 +672,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
}, [sourceId, versionId])
|
||||
|
||||
useEffect(() => { refreshLogMeta(versionId) }, [versionId])
|
||||
// Reordering on the Baseline page takes effect here without a reload: the
|
||||
// expressions are rebuilt and the pivot re-renders.
|
||||
// Re-run when the ordering could have changed on the Baseline page. logMeta is
|
||||
// the signal, not the source: the function reads the ordering itself.
|
||||
useEffect(() => { if (tableRef.current) syncOrderExpressions() }, [logMeta, versionId])
|
||||
// Relabelling on the Baseline page needs a reload to show here: the label is
|
||||
// part of the aggregated row, so the pivot cannot re-derive it in place.
|
||||
useEffect(() => { refreshTags(sourceId) }, [sourceId])
|
||||
|
||||
// Stream an Arrow IPC endpoint into one buffer, reporting download progress.
|
||||
@ -668,6 +684,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
|
||||
const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
|
||||
const total = parseInt(r.headers.get('Content-Length') || '0') || null
|
||||
if (rowCount) setLoadRows(rowCount)
|
||||
const reader = r.body.getReader()
|
||||
const chunks = []
|
||||
let received = 0
|
||||
@ -704,6 +721,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
setLoading(true)
|
||||
setLargeDataset(false)
|
||||
setLoadProgress(null)
|
||||
setLoadRows(null)
|
||||
// deliberately not awaited: it is a count over the whole forecast table and
|
||||
// the load must not wait on it
|
||||
fetch(`/api/versions/${vid}/table-info`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(info => { if (info?.rows && initIdRef.current === myId) setLoadRows(n => n ?? info.rows) })
|
||||
.catch(() => {})
|
||||
setSlices([])
|
||||
setExpandDepth(null)
|
||||
adoptSplit([], 0)
|
||||
@ -804,19 +828,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||
}
|
||||
|
||||
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
|
||||
// After the layout, not before: restoring a saved config replaces
|
||||
// `expressions` wholesale, so expressions applied earlier were being wiped
|
||||
// by the very next line and the ordering columns never appeared in the
|
||||
// column list.
|
||||
// Its own try: the restore above has one, but this sits outside it, and a
|
||||
// throw here would abort the rest of initViewer silently.
|
||||
try {
|
||||
await syncOrderExpressions()
|
||||
await ensureRowLabelWidth()
|
||||
} catch (err) {
|
||||
// Its own try covers only restore(); save() and the builders sit outside
|
||||
// it, and a throw there would abort the rest of initViewer silently.
|
||||
console.error('[pf-order] threw', err)
|
||||
flash(`Column ordering failed: ${err.message || err}`, 'error')
|
||||
console.error('[pf-layout] threw', err)
|
||||
}
|
||||
|
||||
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
|
||||
@ -845,7 +862,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
if (!detail.row) return
|
||||
const config = await viewer.save()
|
||||
if (!(config.group_by || []).length) return
|
||||
const s = sliceFromFilters((detail.config || {}).filter || [])
|
||||
const s = sliceFromFilters((detail.config || {}).filter || [],
|
||||
(config.columns || []).filter(Boolean))
|
||||
if (!Object.keys(s).length) return
|
||||
// the CustomEvent carries no modifier flags, so read them off the
|
||||
// mousedown that produced it (captured on window below)
|
||||
@ -871,6 +889,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
viewer.addEventListener('perspective-select', viewer._pspSelect)
|
||||
|
||||
gridRef.current = await viewer.getPlugin()
|
||||
applyGroupRules()
|
||||
setLargeDataset(false)
|
||||
|
||||
} catch (err) {
|
||||
@ -934,102 +953,75 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the ordering expressions in step with the version's bucket_order and the
|
||||
// log's seq values. Merged into the live config rather than replacing
|
||||
// expressions, so anything the user defined themselves survives.
|
||||
// Rule off the column groups, and mark each group's subtotal.
|
||||
//
|
||||
// Removing an ordering removes its column: leaving a stale Bucket expression
|
||||
// behind would keep ordering by an order that no longer exists.
|
||||
// Silent unless localStorage.pf_debug is set, like the rest of the tracing.
|
||||
// Kept rather than removed: it took several rounds to work out that this ran
|
||||
// before its inputs existed, and it will be wanted again.
|
||||
function dbgOrder(msg, extra) {
|
||||
try { if (!localStorage.getItem('pf_debug')) return } catch { return }
|
||||
if (extra === undefined) console.log(`[pf-order] ${msg}`)
|
||||
else console.log(`[pf-order] ${msg}`, extra)
|
||||
}
|
||||
|
||||
// Fetches its own inputs rather than reading the `versions` prop and the
|
||||
// `logMeta` state.
|
||||
// Scanning across "prior · plan · forecast, each with twelve months and a
|
||||
// total" is twelve columns of identical-looking numbers with nothing to say
|
||||
// where one domain ends and the next begins. The annual figures and the
|
||||
// monthly ones read as one run.
|
||||
//
|
||||
// Those are populated asynchronously, and this runs from initViewer — which
|
||||
// finishes well before them on a large load. It was therefore called with
|
||||
// versionFound: false and logMetaCount: 0 every time, could never build
|
||||
// anything, and the effect meant to re-run it once the data arrived never
|
||||
// fired. Two small queries are worth more than the right timing.
|
||||
async function syncOrderExpressions() {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
// Done through regular_table's style listener rather than CSS: which column
|
||||
// starts a group, and which one is a group's subtotal, are facts about the
|
||||
// data that only the cell metadata knows. The listener runs on every draw, so
|
||||
// it survives scrolling and virtualisation -- a stylesheet cannot, since the
|
||||
// DOM cells are recycled across columns as you scroll.
|
||||
// Blank for this purpose means "no value at this level", which Perspective
|
||||
// writes as a zero-width space rather than an empty string.
|
||||
const notBlank = (v) =>
|
||||
v != null && String(v).replace(/[\s\u200b-\u200d\ufeff]/g, '') !== ''
|
||||
|
||||
let bucketOrder = null
|
||||
let entries = []
|
||||
try {
|
||||
const [vers, log] = await Promise.all([
|
||||
fetch(`/api/sources/${sourceId}/versions`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`/api/versions/${versionId}/log`).then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
bucketOrder = (Array.isArray(vers) ? vers : [])
|
||||
.find(v => String(v.id) === String(versionId))?.bucket_order ?? null
|
||||
entries = Array.isArray(log) ? log : []
|
||||
} catch (err) {
|
||||
console.error('[pf-order] could not read the ordering', err)
|
||||
return
|
||||
function applyGroupRules() {
|
||||
const grid = gridRef.current
|
||||
const table = grid?.regular_table
|
||||
if (!table || table._pfGroupRules) return
|
||||
table._pfGroupRules = true
|
||||
|
||||
// The grid lives in a shadow root, so a stylesheet on the page cannot reach
|
||||
// these cells. Inject into whichever root actually contains the table.
|
||||
// currentColor rather than a fixed grey, so the rule follows the theme
|
||||
// instead of vanishing against Pro Dark.
|
||||
const root = table.getRootNode() || document
|
||||
if (!root.querySelector('#pf-group-rules')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'pf-group-rules'
|
||||
style.textContent = `
|
||||
td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; }
|
||||
td.pf-subtotal { font-weight: 600; background: color-mix(in srgb, currentColor 7%, transparent); }
|
||||
`
|
||||
;(root.head || root).appendChild(style)
|
||||
}
|
||||
|
||||
const byId = Object.fromEntries(entries.map(e => [e.id, e]))
|
||||
const wanted = buildOrderExpressions(bucketOrder, byId)
|
||||
dbgOrder('inputs', {
|
||||
bucket_order: bucketOrder,
|
||||
seqs: entries.filter(e => e.seq != null).map(e => `${e.tag || e.note}=${e.seq}`),
|
||||
wanted: Object.keys(wanted),
|
||||
table.addStyleListener(() => {
|
||||
const body = table.querySelectorAll('tbody td')
|
||||
// The deepest column path is a leaf; anything shorter is an aggregate of
|
||||
// the levels below it, which is what makes a subtotal a subtotal.
|
||||
//
|
||||
// The empty levels are not empty strings. Perspective pads a subtotal's
|
||||
// path with zero-width spaces -- ['04 - Forecast', '\u200b', 'sales_usd']
|
||||
// -- so every path is the same length and a naive `!== ''` test finds no
|
||||
// subtotals at all.
|
||||
let depth = 0
|
||||
const metas = []
|
||||
for (const td of body) {
|
||||
let meta
|
||||
try { meta = table.getMeta(td) } catch { meta = null }
|
||||
metas.push([td, meta])
|
||||
const path = meta?.column_header
|
||||
if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length)
|
||||
}
|
||||
|
||||
let prevGroup = null
|
||||
for (const [td, meta] of metas) {
|
||||
td.classList.remove('pf-group-start', 'pf-subtotal')
|
||||
const path = meta?.column_header
|
||||
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
|
||||
const named = path.filter(notBlank)
|
||||
const group = named[0]
|
||||
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
|
||||
if (named.length < depth) td.classList.add('pf-subtotal')
|
||||
}
|
||||
})
|
||||
|
||||
const { table: _t, ...cfg } = await viewer.save()
|
||||
dbgOrder('saved config read', { existing: Object.keys(cfg.expressions || {}) })
|
||||
const current = cfg.expressions || {}
|
||||
const managed = [...Object.values(ORDER_EXPR_NAMES), ...LEGACY_ORDER_EXPR_NAMES]
|
||||
|
||||
const next = { ...current }
|
||||
for (const name of managed) delete next[name]
|
||||
Object.assign(next, wanted)
|
||||
|
||||
const same = JSON.stringify(next) === JSON.stringify(current)
|
||||
if (same) {
|
||||
// Nothing to do is a legitimate outcome, but "nothing was ordered" and
|
||||
// "the ordering is already applied" look identical from the outside.
|
||||
if (Object.keys(wanted).length === 0) {
|
||||
dbgOrder('no ordering to apply', {
|
||||
bucket_order: version?.bucket_order,
|
||||
seqs: Object.entries(logMeta || {})
|
||||
.filter(([, m]) => m.seq != null)
|
||||
.map(([id, m]) => `${id}:${m.tag || m.note}=${m.seq}`),
|
||||
})
|
||||
}
|
||||
else dbgOrder('exit: already applied', Object.keys(next))
|
||||
return
|
||||
}
|
||||
dbgOrder('applying', Object.keys(next))
|
||||
|
||||
// An expression the pivot is using cannot simply vanish; drop it from the
|
||||
// axes first or restore() rejects the config.
|
||||
const stillValid = (col) => next[col] !== undefined || !managed.includes(col)
|
||||
try {
|
||||
await viewer.restore({
|
||||
...cfg,
|
||||
expressions: next,
|
||||
columns: (cfg.columns || []).filter(c => c == null || stillValid(c)),
|
||||
group_by: (cfg.group_by || []).filter(stillValid),
|
||||
split_by: (cfg.split_by || []).filter(stillValid),
|
||||
sort: (cfg.sort || []).filter(([c]) => stillValid(c)),
|
||||
})
|
||||
dbgOrder('applied', Object.keys(next))
|
||||
} catch (err) {
|
||||
// Surfaced rather than logged: this failing silently is what made the
|
||||
// missing Bucket and Segment columns so hard to pin down -- the columns
|
||||
// simply were not there, with nothing to say why.
|
||||
console.error('[syncOrderExpressions]', err, { wanted, next })
|
||||
flash(`Column ordering failed: ${err.message || err}`, 'error')
|
||||
}
|
||||
table.draw()
|
||||
}
|
||||
|
||||
// Size every column to its contents.
|
||||
@ -1378,15 +1370,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
|
||||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||
|
||||
// restore() replaces `expressions` wholesale, so loading a layout drops the
|
||||
// ordering columns — and a layout saved before they existed has none to put
|
||||
// back. Re-applied after every restore that comes from a saved config, which
|
||||
// is the general form of the fix initViewer already needed.
|
||||
await syncOrderExpressions()
|
||||
await ensureRowLabelWidth()
|
||||
|
||||
setActiveLayoutId(layout.id)
|
||||
// After the sync, so the persisted copy carries the expressions too.
|
||||
// The persisted copy is taken after the restore, so it carries whatever
|
||||
// cleanLayout dropped on the way in rather than the stale original.
|
||||
const merged = await captureConfig()
|
||||
await persistLayout(versionId, merged || cfg)
|
||||
}
|
||||
@ -1409,6 +1397,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
async function submitOp(op) {
|
||||
if (!slices.length) { flash('Select a slice first', 'error'); return }
|
||||
|
||||
// The pivot's filter scopes what the ledger counted, so it has to scope what
|
||||
// gets written too -- otherwise the panel shows one number and the operation
|
||||
// changes a larger set. It travels as [col, op, value] rather than folded
|
||||
// into the slices, because a slice is {col: value} and can only mean
|
||||
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
|
||||
const body = buildPayload(op)
|
||||
if (!body) return
|
||||
if (body.slices.some(sl => !Object.keys(sl).length)) {
|
||||
@ -1516,7 +1509,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
.map(([id, m]) => ({
|
||||
id: Number(id),
|
||||
operation: m.operation,
|
||||
label: (m.tag || m.note || '').trim(),
|
||||
label: (m.label || m.tag || m.note || '').trim(),
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
|
||||
@ -1525,6 +1518,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
// Not col_meta columns, but real ones here and resolvable server-side to the
|
||||
// set of pf.log ids that carry the name. Dropping them is what let a click on
|
||||
// one bucket's cell scale every bucket at that intersection.
|
||||
if (COMPUTED_SLICE_COLS.has(k)) { out[k] = v; continue }
|
||||
if (dimCols.has(k)) { out[k] = v; continue }
|
||||
if (dateCols.has(k)) {
|
||||
const ms = Number(v)
|
||||
@ -1534,7 +1531,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
return out
|
||||
}
|
||||
|
||||
// The scope is read from the ref rather than passed in: the request preview in
|
||||
// the panel calls this too, and when it was a parameter the preview defaulted
|
||||
// it away -- showing a payload with no scope for a write that had one.
|
||||
function buildPayload(op) {
|
||||
const viewFilter = viewFilterRef.current || []
|
||||
if (!slices.length) return null
|
||||
// Two clicked cells can differ only by a column the operation cannot filter on
|
||||
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become
|
||||
@ -1553,6 +1554,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
let body = {
|
||||
tag: opTag.trim() || undefined,
|
||||
slices: effectiveSlices,
|
||||
...(viewFilter.length ? { scope: viewFilter } : {}),
|
||||
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
|
||||
}
|
||||
if (op === 'scale') {
|
||||
@ -1702,6 +1704,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
distinctSlices,
|
||||
dock,
|
||||
slices, setSlices,
|
||||
viewScope,
|
||||
applyMode, setApplyMode,
|
||||
currentTotals,
|
||||
activeOp, setActiveOp,
|
||||
@ -1973,7 +1976,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
<div className="relative flex-1 min-w-0 min-h-0">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 z-10 gap-2">
|
||||
<span className="text-sm text-gray-400">Loading…</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
{loadRows ? `Loading ${loadRows.toLocaleString()} rows…` : 'Loading…'}
|
||||
</span>
|
||||
{loadProgress && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
@ -2121,12 +2126,22 @@ function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listI
|
||||
const META_COL_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/
|
||||
|
||||
// Perspective encodes a clicked/selected row position as [col, '==', value] triples
|
||||
function sliceFromFilters(filters) {
|
||||
// `measures` is the view's `columns` list. Clicking a cell whose column axis is
|
||||
// collapsed makes Perspective emit the measure name as the value of the first
|
||||
// hidden split_by dimension -- a bucket subtotal arrives as
|
||||
// ["smon_e", "==", "sales_usd"] -- because the engine maps split_by positionally
|
||||
// over a column name that no longer has that many segments. Left in, the slice
|
||||
// asks for a month equal to a measure, matches nothing, and the operation
|
||||
// silently has no rows to act on.
|
||||
function sliceFromFilters(filters, measures = []) {
|
||||
const measureSet = new Set(measures)
|
||||
const s = {}
|
||||
for (const f of filters) {
|
||||
if (!Array.isArray(f)) continue
|
||||
const [col, op, val] = f
|
||||
if (op === '==' && val != null) s[col] = String(val)
|
||||
if (op !== '==' || val == null) continue
|
||||
if (measureSet.has(String(val))) continue
|
||||
s[col] = String(val)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user