diff --git a/CLAUDE.md b/CLAUDE.md index 443e1b7..5dd76a6 100644 --- a/CLAUDE.md +++ b/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. diff --git a/lib/sql_generator.js b/lib/sql_generator.js index 8ff165f..3d655b9 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -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 - ,l.operation AS pf_op + ,${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 }; diff --git a/routes/log.js b/routes/log.js index 130dd41..3f4614c 100644 --- a/routes/log.js +++ b/routes/log.js @@ -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' }); diff --git a/routes/operations.js b/routes/operations.js index 067e5be..d4a7b48 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -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 {} diff --git a/routes/versions.js b/routes/versions.js index 7087bed..1750a46 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -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 + name = COALESCE($2, name), + description = COALESCE($3, description), + exclude_iters = COALESCE($4, exclude_iters), + 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' }); diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index a2e7b9c..7b88c9e 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -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 diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index 7802673..f61b71d 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -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() diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 566ff1a..917bd5e 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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 (

@@ -126,6 +137,12 @@ function SelectionList({ slices, currentTotals, onRemove, onClear }) { return (

+ {scopeLine && ( +
+ within + {scopeLine} +
+ )}
@@ -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 + {/* 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 => ( + + + {measures.map(m => ( + + ))} + + ))} + + {exclLines.length > 0 && ( + {rule}{measures.map(m => )} + )} + {/* the walk from baseline to current, by initiative */} {lines.map(e => ( @@ -440,20 +503,6 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se ))} - {/* Rows the pivot shows but operations cannot write. Listed so the - panel's figures reconcile with what the grid displays. */} - {hasExcl && ( - - - {measures.map(m => ( - - ))} - - )} {hasExcl && ( @@ -468,6 +517,19 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se )} + {/* 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 && ( + + + + )} + {rule}{measures.map(m => )} {/* 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({ )} setSlices(prev => prev.filter((_, x) => x !== i))} onClear={() => setSlices([])} diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index a41b18e..5a862f8 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -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 (
-
+ {/* 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. */} +
{msg && (
@@ -384,6 +397,31 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio )}
+ {/* 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 && ( +
+ Fallback names + {[ + ['adjustment_segment', 'Adjustment segment', '99 - Adjustments'], + ['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'], + ['unlabeled_load', 'Unlabeled load', 'Unlabeled'], + ].map(([field, label, builtin]) => ( +
+ + saveVersionName(field, e.target.value)} + placeholder={builtin} + className="border border-gray-200 rounded px-2 py-1 text-sm w-48" /> +
+ ))} +
+ )} + {showNewVersion && (
@@ -409,61 +447,27 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {versionId && <> {/* Segments loaded */} -
+
Segments loaded
- {/* 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 && ( -
- column order - {bucketOrder.map((b, i) => ( - - {String(i + 1).padStart(2, '0')} - - {b} - - - - ))} -
- )} - - -
+ {seg.name} + + final + + + {m.key === 'price' ? fmtNum(priceOf(seg), m.dp) : fmtNum(seg[m.key], m.dp)} +
- {exclName} · fixed - - {m.key === 'price' ? fmtNum(priceOf(excl), m.dp) : fmtNum(excl[m.key], m.dp)} -
+ Nothing in this selection can be adjusted — all of it is {exclName}, + loaded as {(currentTotals?.excludedIters || []).join(' / ') || 'reference'}. +
+
- - - + + + + @@ -473,11 +477,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - + )} {!showAddForm && !editingLogId && ( - - + {/* 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. */} + + {/* 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. */} + {isOpen && ( -
#seqnotecounts towardkindlabelnotecounts toward rows {log[0]?.value_col || 'value'} by
No segments loaded yet
No segments loaded yet
+ {isOpen ? '▾' : '▸'} {log.length - i} e.stopPropagation()}> - 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" /> - - + {entry.operation} - {entry.note || } + e.stopPropagation()}> + 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" /> + + {entry.note + ?
{entry.note}
+ : }
e.stopPropagation()}> 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
+
@@ -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({ - {/* Description (edit only) */} - {mode === 'edit' && ( -
- - 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" /> -
- )} - {/* Filters */}
@@ -830,8 +843,24 @@ function SegmentForm({
)} - {/* Note + submit */} -
+ {/* 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. */} +
+
+ + setSegLabel(e.target.value)} + placeholder="defaults to the note" className={`${baseInp} text-sm py-1.5`} /> +
+
+ + setSegBucket(e.target.value)} + list="pf-bucket-options" placeholder="e.g. 02 - Forecast" + className={`${baseInp} text-sm py-1.5`} /> +
setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} /> diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index b85d8fc..3618c8f 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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), - }) - - 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}`), - }) + 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) } - 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') - } + 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') + } + }) + 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
{loading && (
- Loading… + + {loadRows ? `Loading ${loadRows.toLocaleString()} rows…` : 'Loading…'} + {loadProgress && ( <> @@ -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 }