diff --git a/CLAUDE.md b/CLAUDE.md index 9fdb323..15b9100 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,11 @@ routes/ auth.js POST /api/login, /api/logout, GET /api/me; login throttle tables.js GET /api/tables, /api/tables/:schema/:tname/preview sources.js Source registration, col_meta, SQL generation - versions.js Version CRUD, baseline/reference load, data stream + versions.js Version CRUD, baseline/reference load, data stream, tag completions operations.js scale, recode, clone, undo — the core forecast ops log.js GET /api/versions/:id/log, DELETE /api/log/:logid layouts.js Named pivot layouts — list per version, create, patch, delete + plans.js Planned load segments — list, create, patch, delete lib/ sql_generator.js buildFilterClause, token substitution helpers auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI @@ -65,6 +66,7 @@ ui/src/ - **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it. - **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb - **`pf.layout`** — named Perspective view configs; see §Pivot layouts +- **`pf.load_plan`** — load segment definitions, before and apart from having been run; see §Cloning a version's definition - **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time - **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at` - **`pf.session`** — express-session store (connect-pg-simple layout) @@ -236,10 +238,18 @@ Both axes collapse the same way: a **depth in `ViewConfig`**, set through - **Columns** — `split_by_depth`, driven by the `Columns` buttons via `applySplitDepth()` -Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field -list (`groupFull` / `splitFull`), so each button is named after the level it -reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and -offered levels a two-field `group_by` does not have. +Both axes are drawn by `components/DepthButtons.jsx` from the axis's own field +list (`groupFull` / `splitFull`), as a **stepper**: `◂ partgroup ▸`, with the +middle button opening the full level list for a jump rather than a step. + +Three controls per axis whatever the depth. It was one button per level, which +grows with the pivot — a 5-by-3 layout rendered ten of them — and the current +level was a blue button among the greys, so reading the state meant scanning. +Before that the row axis was a fixed `0 1 2 3`, which named nothing and offered +levels a two-field `group_by` does not have. + +`depth` is null until the first layout has been read back; the stepper shows an +em dash rather than guessing a level. Both are **1-based**: they count the levels to show, where the imperative `view.set_depth()` counts the boundary below them. `server.cpp` does @@ -321,6 +331,93 @@ together. `columns` selects which *measures* appear, not individual split combinations. +## Tag completions are per version + +`GET /versions/:id/tags` — the tags already used on **this** version, with their +use counts, for completing the next adjustment. `Forecast.jsx` refetches on +`versionId` and after every tagged write. + +It used to be `GET /sources/:id/tags`, joined through `pf.version` and grouped +across every version of the source. A tag names an initiative *inside* a +scenario, so that put last year's work in front of this year's and reported a +`uses` count earned on a version the tag did not belong to. The cost of the +narrower scope is that a fresh version completes from nothing until it has a tag +of its own — which is the honest state of it, since cloning a definition copies +no adjustments. + +The `pf_bucket` datalist on the Baseline page was already per version +(`bucketsInUse`), so the two now agree. + +**Completion is the datalist and nothing else.** The operation panel used to +carry six quick-pick pills under the tag field as well. They wrapped to two rows +as soon as the tags were longer than a word, and the panel docked at the bottom +has a fixed height, so they pushed the form past its own border. The datalist +costs no space, is not capped at six, and now shows the tag alone — an option's +text content renders as a second column beside the value, so the use count was +widening every row of the pick list to carry a number nobody picks on. `uses` +and `last_used` still come back from the route; nothing reads them. + +--- + +## Renaming a version + +Safe at any time, including on a closed one. Nothing derives from the name: the +forecast table is `fc_{tname}_{version_id}`, `pf.log` and `pf.layout` key on the +id, and the UI's `localStorage` keys are id-based. The Baseline page's **This +forecast** panel saves `name` and `description` on blur through +`PUT /api/versions/:id`, which has accepted both all along — it was only ever +called for the fallback names. + +Two edges the panel handles: `(source_id, name)` is unique, so the route +answers 409 on a collision rather than a raw Postgres error; and the `UPDATE` +COALESCEs a falsy name back to the existing one, so a blank box is refused +client-side rather than looking saved and changing nothing. + +--- + +## Cloning a version's definition + +`POST /sources/:id/versions` takes `clone_from`. It copies the template +version's settings (`description`, `exclude_iters`, the three fallback names, +`bucket_order`), its **version-scoped** layouts, and its load segments — and +**runs nothing**. The reason to clone a definition is to change the dates before +anything loads, and a clone that fetched two million rows first would have to be +undone before it could be edited. + +Not copied: adjustments, in any form. A scale's target is a figure sized against +the rows that were there, so replaying one against a different load is +arithmetic nobody asked for. Source-wide layouts (`version_id IS NULL`) are not +copied either — they already apply to the new version, and a copy would +duplicate the name. + +### `pf.load_plan` + +A segment used to exist only once it had been run: a `pf.log` entry with +forecast rows behind it. A plan is the definition on its own — `kind`, `label`, +`bucket`, `tag`, `note`, `filters`, `raw_where`, `date_offset`, `seq` — with +`run_logid` naming the `pf.log` entry it produced, or null while it is still a +plan. + +**Deliberately not `pf.log`.** Every row there means a write happened; holding +unexecuted intent in the audit log would cost it that meaning, and the change +log would list entries that never ran. + +`run_logid` is `ON DELETE SET NULL`, so undoing a load returns its plan to unrun +rather than deleting the definition with it. The plans list reads the row count +back through that FK, which is how it tells run from unrun. + +**Derivation from an old version.** `cloneDefinition()` in `routes/versions.js` +reads `pf.load_plan` when the template has plans and falls back to deriving them +from its `baseline`/`reference` log entries when it does not — which is every +version built before plans existed. `pf.log.params` already stores `filters`, +`raw_where` and `date_offset`, so this is a read, not a reconstruction. + +**Run all stops at the first failure.** The segments of a definition are not +independent — a baseline followed by references that date-shift off it — so +carrying on past a broken one builds something nobody described. + +--- + ## Pivot layouts A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an diff --git a/routes/plans.js b/routes/plans.js new file mode 100644 index 0000000..5a03614 --- /dev/null +++ b/routes/plans.js @@ -0,0 +1,125 @@ +const express = require('express'); +const { sessionUser } = require('../lib/auth'); + +// Planned load segments — a version's definition, before it has been run. +// +// A plan carries exactly what POST /versions/:id/{baseline|reference} takes, so +// running one is that same call with the plan's fields; the client then stamps +// run_logid. It is two round trips rather than one because the alternative is a +// second copy of the load route's token substitution, and a plan that reads +// unrun while its rows exist is visible and fixable, where a divergent copy of +// the load SQL is neither. +module.exports = function(pool) { + const router = express.Router(); + + const FIELDS = ['seq', 'kind', 'label', 'bucket', 'tag', 'note', + 'filters', 'raw_where', 'date_offset']; + + // A plan whose log entry has been undone is a plan again: the FK is + // ON DELETE SET NULL, so run_logid clears itself. The join is for the + // row count, which is what makes "already run" worth seeing. + const SELECT_PLANS = ` + SELECT + p.* + ,l.row_count AS run_rows + ,l.stamp AS run_at + FROM pf.load_plan p + LEFT JOIN pf.log l + ON l.id = p.run_logid + WHERE TRUE + AND p.version_id = $1 + ORDER BY + p.seq + ,p.id`; + + router.get('/versions/:id/plans', async (req, res) => { + try { + const { rows } = await pool.query(SELECT_PLANS, [parseInt(req.params.id)]); + res.json(rows); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.post('/versions/:id/plans', async (req, res) => { + const versionId = parseInt(req.params.id); + const b = req.body || {}; + if (!['baseline', 'reference'].includes(b.kind)) { + return res.status(400).json({ error: 'kind must be baseline or reference' }); + } + try { + // Append by default: a new plan runs after the ones already there. + const next = await pool.query( + `SELECT COALESCE(max(seq), -1) + 1 AS seq FROM pf.load_plan WHERE version_id = $1`, + [versionId] + ); + const { rows } = await pool.query( + `INSERT INTO pf.load_plan + (version_id, seq, kind, label, bucket, tag, note, filters, raw_where, + date_offset, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, '0 days'), $11) + RETURNING *`, + [versionId, b.seq ?? next.rows[0].seq, b.kind, + b.label || null, b.bucket || null, b.tag || null, b.note || null, + b.filters ? JSON.stringify(b.filters) : null, b.raw_where || null, + b.date_offset || null, sessionUser(req)] + ); + res.status(201).json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // Any subset of the fields, plus run_logid when a run has just happened. + // The flag-and-value pair rather than COALESCE on the value: clearing a + // label to blank has to be distinguishable from not mentioning it. + router.patch('/plans/:pid', async (req, res) => { + const pid = parseInt(req.params.pid); + const b = req.body || {}; + const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined); + if (!keys.length) return res.status(400).json({ error: 'Nothing to update' }); + if (b.kind !== undefined && !['baseline', 'reference'].includes(b.kind)) { + return res.status(400).json({ error: 'kind must be baseline or reference' }); + } + try { + const sets = []; + const params = [pid]; + for (const k of keys) { + let v = k === 'filters' + ? (b[k] === null ? null : JSON.stringify(b[k])) + : (typeof b[k] === 'string' ? (b[k].trim() || null) : b[k]); + // date_offset is NOT NULL, so a blank one means the default. + // Coerced here rather than in the statement: a second assignment + // to the same column is an error, not a fallback. + if (k === 'date_offset' && !v) v = '0 days'; + params.push(v); + sets.push(`${k} = $${params.length}${k === 'filters' ? '::jsonb' : ''}`); + } + const { rows } = await pool.query( + `UPDATE pf.load_plan + SET ${sets.join(', ')}, updated_at = now() + WHERE id = $1 + RETURNING *`, + params + ); + if (!rows.length) return res.status(404).json({ error: 'Plan not found' }); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.delete('/plans/:pid', async (req, res) => { + try { + const { rows } = await pool.query( + `DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)] + ); + if (!rows.length) return res.status(404).json({ error: 'Plan not found' }); + res.json({ deleted: rows[0].id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/versions.js b/routes/versions.js index 11e2652..356f191 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -3,6 +3,74 @@ const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils'); const { sessionUser, sessionTerritory } = require('../lib/auth'); const { buildTerritoryClause } = require('../lib/sql_generator'); +// Copy a version's definition onto a freshly created one: its load segments as +// plans, and its saved layouts. +// +// The plans come from pf.load_plan when the template has them, and are derived +// from its baseline/reference log entries when it does not — which is every +// version created before plans existed, so a definition can be lifted out of +// one that was built by hand. `params` on a load entry already stores the +// filters, the raw WHERE when one was used, and the date offset, so the +// derivation is a read rather than a reconstruction. +async function cloneDefinition(client, template, version, created_by) { + const existing = await client.query( + `SELECT * FROM pf.load_plan WHERE version_id = $1 ORDER BY seq, id`, + [template.id] + ); + + let source = existing.rows.map(p => ({ + kind: p.kind, label: p.label, bucket: p.bucket, tag: p.tag, note: p.note, + filters: p.filters, raw_where: p.raw_where, date_offset: p.date_offset + })); + + if (!source.length) { + const log = await client.query( + `SELECT operation, label, bucket, tag, note, params + FROM pf.log + WHERE TRUE + AND version_id = $1 + AND operation IN ('baseline', 'reference') + ORDER BY id`, + [template.id] + ); + source = log.rows.map(e => ({ + kind: e.operation, + label: e.label, bucket: e.bucket, tag: e.tag, note: e.note, + filters: e.params?.filters || null, + raw_where: e.params?.raw_where || null, + date_offset: e.params?.date_offset || '0 days' + })); + } + + for (const [i, p] of source.entries()) { + await client.query( + `INSERT INTO pf.load_plan + (version_id, seq, kind, label, bucket, tag, note, filters, raw_where, + date_offset, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, COALESCE($10, '0 days'), $11)`, + [version.id, i, p.kind, p.label, p.bucket, p.tag, p.note, + p.filters ? JSON.stringify(p.filters) : null, p.raw_where, + p.date_offset, created_by] + ); + } + + // Version-scoped layouts only. The source-wide ones (version_id IS NULL) + // already apply to the new version, so copying them would duplicate them + // under a name that now exists twice. + const layouts = await client.query( + `INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default) + SELECT source_id, $2, name, config, owner, visibility, is_default + FROM pf.layout + WHERE TRUE + AND version_id = $1 + RETURNING id`, + [template.id, version.id] + ); + + return { from: template.id, from_name: template.name, + plans: source.length, layouts: layouts.rowCount }; +} + module.exports = function(pool) { const router = express.Router(); @@ -24,7 +92,7 @@ module.exports = function(pool) { // inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction router.post('/sources/:id/versions', async (req, res) => { const sourceId = parseInt(req.params.id); - const { name, description, exclude_iters } = req.body; + const { name, description, exclude_iters, clone_from } = req.body; const created_by = sessionUser(req); if (!name) return res.status(400).json({ error: 'name is required' }); @@ -62,19 +130,52 @@ module.exports = function(pool) { }); } + // clone_from copies a version's *definition* -- its settings, its + // saved layouts, and its load segments as plans -- and stops there. + // The loads are deliberately not run: the reason to clone a + // definition is to change the dates before it loads anything, and a + // clone that fetched two million rows first would have to be undone + // before it could be edited. + // + // Adjustments are not copied in any form. A scale's target is a + // figure sized against the rows that were there, so replaying one + // against a different load is arithmetic nobody asked for. + let template = null; + if (clone_from) { + const t = await client.query( + `SELECT * FROM pf.version WHERE id = $1 AND source_id = $2`, + [parseInt(clone_from), sourceId] + ); + if (!t.rows.length) { + return res.status(404).json({ + error: 'Version to clone not found, or it belongs to a different source' + }); + } + template = t.rows[0]; + } + await client.query('BEGIN'); // insert version to get id const verResult = await client.query(` - INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters, + adjustment_segment, adjustment_bucket, unlabeled_load, + bucket_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * `, [ sourceId, name, - description || null, + // An explicit description wins; otherwise the template's is + // carried, since it describes the definition being copied. + description || template?.description || null, created_by || null, - exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]' + exclude_iters ? JSON.stringify(exclude_iters) + : (template ? JSON.stringify(template.exclude_iters) : '["reference"]'), + template?.adjustment_segment || null, + template?.adjustment_bucket || null, + template?.unlabeled_load || null, + template?.bucket_order ? JSON.stringify(template.bucket_order) : null ]); const version = verResult.rows[0]; @@ -109,8 +210,11 @@ ${colDefs}, `CREATE INDEX ${table.split('.').pop()}_logid_idx ON ${table} (pf_logid)` ); + let cloned = null; + if (template) cloned = await cloneDefinition(client, template, version, created_by); + await client.query('COMMIT'); - res.status(201).json({ ...version, fc_table: table }); + res.status(201).json({ ...version, fc_table: table, cloned }); } catch (err) { await client.query('ROLLBACK'); console.error(err); @@ -252,18 +356,29 @@ ${colDefs}, // Tags already used on this source, newest first — feeds the tag autocomplete. // Scoped to the source rather than the version so an initiative name carries // across versions, which is the point of naming it. - router.get('/sources/:id/tags', async (req, res) => { + // Tags already used on one version, for completing the next adjustment. + // + // Scoped to the version, not the source. A tag names an initiative inside a + // scenario -- what this plan is doing -- so offering every tag anyone has + // ever used on the source put last year's work in the way of this year's, + // and the `uses` count read as activity on a version it did not belong to. + // The cost is that a fresh version completes from nothing until it has a + // tag of its own, which is the honest state of it: cloning a definition + // copies no adjustments, so it has no initiatives yet. + router.get('/versions/:id/tags', async (req, res) => { try { const result = await pool.query(` SELECT l.tag, count(*)::int AS uses, max(l.stamp) AS last_used FROM pf.log l - JOIN pf.version v ON v.id = l.version_id - WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> '' + WHERE TRUE + AND l.version_id = $1 + AND l.tag IS NOT NULL + AND l.tag <> '' GROUP BY l.tag ORDER BY max(l.stamp) DESC - `, [req.params.id]); + `, [parseInt(req.params.id)]); res.json(result.rows); } catch (err) { console.error(err); @@ -359,6 +474,13 @@ ${colDefs}, res.json(result.rows[0]); } catch (err) { console.error(err); + // (source_id, name) is unique, so a rename can collide. The POST has + // said so for as long as it has existed; this said it with a raw + // Postgres message, which reads as a server fault rather than a name + // that is already taken. + if (err.code === '23505') { + return res.status(409).json({ error: 'A version with that name already exists for this source' }); + } res.status(500).json({ error: err.message }); } }); diff --git a/server.js b/server.js index 2c2f543..aced83b 100644 --- a/server.js +++ b/server.js @@ -81,6 +81,7 @@ app.use('/api', require('./routes/versions')(pool)); app.use('/api', require('./routes/operations')(pool)); app.use('/api', require('./routes/log')(pool)); app.use('/api', require('./routes/layouts')(pool)); +app.use('/api', require('./routes/plans')(pool)); const port = process.env.PORT || 3010; diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index 96db788..9577b39 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -261,3 +261,37 @@ WHERE TRUE AND l.version_id IS NULL AND l.is_default ); + +-- pf.load_plan: a segment's definition, before and apart from having been run. +-- +-- Until now a segment existed only once it had been loaded -- as a pf.log entry +-- with forecast rows behind it -- so cloning a version's definition meant +-- re-running every load, and there was nowhere to hold "this is the cut I want, +-- but the dates need changing first". +-- +-- Deliberately not pf.log. That table is the audit log: every row in it means a +-- write happened, and putting unexecuted intent there would cost it that +-- meaning. A plan is intent; run_logid is the pf.log entry it produced. +CREATE TABLE IF NOT EXISTS pf.load_plan ( + id serial PRIMARY KEY, + version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE, + seq integer NOT NULL DEFAULT 0, -- run order + kind text NOT NULL, -- baseline | reference + label text, + bucket text, + tag text, + note text, + filters jsonb, -- the structured filter builder's own shape + raw_where text, -- the raw-SQL escape hatch, when used instead + date_offset text NOT NULL DEFAULT '0 days', + -- the pf.log entry this plan produced, or null while it is still a plan. + -- ON DELETE SET NULL so undoing a load returns its plan to unrun rather than + -- deleting the definition with it. + run_logid bigint REFERENCES pf.log(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by text, + CHECK (kind IN ('baseline', 'reference')) +); + +CREATE INDEX IF NOT EXISTS load_plan_version ON pf.load_plan (version_id, seq, id); diff --git a/ui/src/components/DepthButtons.jsx b/ui/src/components/DepthButtons.jsx index b4cb4f5..b2bdb00 100644 --- a/ui/src/components/DepthButtons.jsx +++ b/ui/src/components/DepthButtons.jsx @@ -1,26 +1,79 @@ -// One axis's collapse control. Rows and columns are the same thing — a depth in -// ViewConfig, counting the levels to show — so they are drawn by one component -// rather than two that happen to look alike. +import { useEffect, useRef, useState } from 'react' + +// One axis's collapse control, as a stepper. // -// The buttons are named after the level they reveal. The row axis used to be a -// fixed 0 1 2 3: a number means nothing without already knowing what group_by -// holds, and a fixed range offers levels that do not exist while hiding ones -// that do. +// Rows and columns are the same thing — a depth in ViewConfig, counting the +// levels to show — so one component draws both. +// +// It used to be one button per level, which is N+1 controls that grow with the +// pivot: a 5-by-3 layout rendered ten of them, and the active level was a blue +// button somewhere among the greys, so reading the current state meant scanning. +// A stepper is three controls whatever the depth, and says where you are rather +// than making you find it. The full list is still one click away, for jumping +// rather than stepping. export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) { + const [open, setOpen] = useState(false) + const boxRef = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) } + const onKey = (e) => { if (e.key === 'Escape') setOpen(false) } + // Capture phase: the pivot lives in a shadow root and retargets its events, + // so a bubbled listener sees the host rather than the real target. + document.addEventListener('mousedown', onDown, true) + window.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown, true) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + const max = levels.length + // depth is null until the first layout has been read back. Showing the last + // level would be a guess; an em dash is honest and the arrows still work. + const known = depth != null + const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1]) + const step = (d) => onPick(Math.min(max, Math.max(0, d))) + + const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' + + 'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors' + return ( -
- {label} - {Array.from({ length: levels.length + 1 }, (_, n) => ( - - ))} +
+ {label} + + + + + + + + {open && ( +
+ {Array.from({ length: max + 1 }, (_, n) => ( + + ))} +
+ )}
) } diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index f6e9193..0bfa855 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -258,6 +258,12 @@ function derive(current, edit, dp = 2) { new: fmtNum(next, dp), change: fmtNum(change, dp), pct: pct == null ? '—' : fmtNum(pct, 1), + // The same three as numbers, for anything that needs to compute with them. + // The strings above are grouped by toLocaleString, and parseFloat stops at + // the first separator -- "3,456,789" reads back as 3 -- so a caller that + // re-parsed the display got a plausible small number rather than an error. + // Nothing should have to know that; the numbers travel alongside. + n: { new: next, change, pct }, } out[edit.field] = edit.raw // keep what you typed exactly as typed return out @@ -283,7 +289,7 @@ function groupDigits(raw) { return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}` } -function LedgerInput({ value, active, onChange, onFocus, suffix }) { +function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix }) { const ref = useRef(null) const caret = useRef(null) const display = groupDigits(value) @@ -305,8 +311,13 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) { try { el.setSelectionRange(pos, pos) } catch {} }, [display]) + // The suffix is positioned inside the box rather than set beside it. As a + // sibling in the flex row it took width of its own, so in a right-aligned cell + // the % row's input sat a suffix-and-a-gap to the left of the rows above it, + // and the three boxes that are meant to be three ways of saying one thing did + // not line up. Inside, every row's box is the same width and the same edge. return ( - + - {suffix && {suffix}} + onKeyDown={e => { if (e.key === 'Escape' && onClear) { e.preventDefault(); onClear(); e.target.blur() } }} + title={held ? 'Held by the Absorbed by setting — type here to set it yourself' : undefined} + className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums transition-colors + ${suffix ? 'pl-2 pr-5' : 'px-2'} + ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' + : held ? 'border-gray-100 bg-gray-50 text-gray-400' + : 'border-gray-200 bg-white text-gray-700'}`} /> + {suffix && ( + {suffix} + )} ) } @@ -433,9 +452,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se // left units and price blank -- exactly the two numbers you need to see to // know whether you asked for a price change or a volume change. const numOf = (key, field) => { - const raw = derived[key]?.[field] - const n = parseFloat(raw) - return isFinite(n) ? n : null + const n = derived[key]?.n?.[field] + return n != null && isFinite(n) ? n : null } const outcome = (() => { @@ -473,18 +491,56 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se } })() + // The same three readings for a measure nobody typed into, taken from where + // the edit actually lands it. Scaling dollars with price holding moves qty, + // and leaving that column blank until the Result row meant the number you had + // just implied was the one thing not shown next to the one you typed. + const backfill = (current, next, dp) => { + if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' } + const change = next - current + const pct = current === 0 ? null : (change / Math.abs(current)) * 100 + return { + new: fmtNum(next, dp), + change: fmtNum(change, dp), + pct: pct == null ? '—' : fmtNum(pct, 1), + n: { new: next, change, pct }, + } + } + + // What the rows render. derived stays the edit-driven map -- it is what + // outcome is computed from, so back-filling into it would feed the result + // back into its own inputs. + const shown = Object.fromEntries(measures.map(m => [ + m.key, + (filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp), + ])) + const setEdit = (key, field, raw) => setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) + // Hand a measure back to being derived. Emptying the box does the same thing + // -- filled() is false either way -- but that is only obvious once you know + // it, and clicking into a held column to look at it and then wanting out + // again is the ordinary case, not the awkward one. + const clearEdit = (key) => + setScaleInputs(prev => { + if (!(key in prev)) return prev + const { [key]: _drop, ...rest } = prev + return rest + }) + // focusing a different row hands that measure's edit to the focused row, // carrying across whatever it currently reads const focusRow = (key, field) => setScaleInputs(prev => { const cur = prev[key] if (cur && cur.field === field) return prev - const shown = derived[key]?.[field] ?? '' - const raw = shown === '—' ? '' : String(shown).replace(/,/g, '') - return { ...prev, [key]: { field, raw: cur ? raw : '' } } + // Whatever the box reads is what you get, including a back-filled + // number: clicking into an implied figure is how you pin it, which is + // the difference between "units may move" and "units are this". + const text = shown[key]?.[field] ?? '' + const raw = text === '—' ? '' : String(text).replace(/,/g, '') + return { ...prev, [key]: { field, raw } } }) // A running total across the walk, in the primary measure. The lines sum to @@ -508,6 +564,14 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se })() const showRunning = !!valueCol && (lines.length + exclLines.length) > 1 + // Which measure the plug holds still. Only meaningful while dollars are the + // sole input -- that is the one case where price and volume are both unknown + // and the toggle is answering for them. 'price' means price absorbs the + // change, so qty holds; 'volume' means qty absorbs it, so price holds. + const heldKey = (valueCol && unitsCol && dollarsOnly) + ? (scalePlug === 'price' ? 'units' : 'price') + : null + const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2' const rule =
@@ -518,9 +582,18 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {measures.map(m => ( - + {m.label} - {m.hint && · {m.hint}} + {m.key === heldKey + ? · holds + : m.hint && · {m.hint}} + {scaleInputs[m.key] && ( + + )} ))} {showRunning && ( @@ -633,10 +706,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se return ( setEdit(m.key, field, raw)} onFocus={() => focusRow(m.key, field)} + onClear={() => clearEdit(m.key)} suffix={field === 'pct' ? '%' : null} /> @@ -666,53 +741,50 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {/* A dollar figure on its own does not say whether price or volume moved. Only ask once the edit is actually dollars-only -- naming units or price has already answered it, and the control would just be noise. */} - {valueCol && unitsCol && dollarsOnly && ( -
-
- Absorbed by - -
-
- {scalePlug === 'price' - ? `${unitsCol} holds; price moves to reach the number.` - : `Price holds; ${unitsCol} scales with the dollars.`} -
-
- )} + {/* Both of these are settings on the edit, so they use Field like every + other one -- an aligned label column and a hint below the control. + They used to hand-roll their own label row and hint, with two + different type sizes and no shared label width, so the two stacked + into a paragraph of loose words rather than reading as two settings. */} + {(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && ( +
- {hasExcl && ( -
-
- Target applies to - -
-

- {onTotal - ? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.` - : `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`} -

-
- )} + {valueCol && unitsCol && dollarsOnly && ( + + + + )} - {perSlice && ( -

- Applied to each slice separately — the figures above are combined totals, - so each slice's own change will differ. -

+ {hasExcl && ( + + + + )} + + {perSlice && ( + + Applied to each slice separately + + )} +
)}
) @@ -898,7 +970,7 @@ export default function OperationPanel({ scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, - opTag, setOpTag, knownTags = [], logMeta = {}, + opTag, setOpTag, logMeta = {}, scaleNote, setScaleNote, recodeSet, setRecodeSet, recodeNote, setRecodeNote, @@ -1066,24 +1138,12 @@ export default function OperationPanel({ )} - {/* Under the tag field and inside the same column, so they read as - values for it rather than as a row of unexplained buttons. */} - {knownTags.length > 0 && ( - -
- {knownTags.slice(0, 6).map(t => ( - - ))} -
-
- )} + {/* The six quick-pick pills that used to sit here are gone. They + wrapped onto two rows once the tags were more than a word long, + which is most of them, and the panel docked at the bottom has a + fixed height to spend -- so they pushed the form past its own + border. The same tags complete from the input's datalist, which + costs no space and is not capped at six. */} setNote(e.target.value)} diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 6e92d01..570a51d 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -96,6 +96,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const [newVerName, setNewVerName] = useState('') const [newVerDesc, setNewVerDesc] = useState('') const [creatingVer, setCreatingVer] = useState(false) + const [cloneFrom, setCloneFrom] = useState('') + // Planned segments: a version's definition before it has been run. Separate + // from `log`, which is what has actually happened. + const [plans, setPlans] = useState([]) + const [editingPlanId, setEditingPlanId] = useState(null) + const [runningAll, setRunningAll] = useState(false) // segment form const [segType, setSegType] = useState('baseline') @@ -130,8 +136,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio }, [sourceId]) useEffect(() => { - if (!versionId) { setLog([]); return } + if (!versionId) { setLog([]); setPlans([]); return } loadLog() + loadPlans() }, [versionId]) // A segment's banner: what it counts toward, independent of pf_iter. Held @@ -196,6 +203,144 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio }) } + function loadPlans() { + fetch(`/api/versions/${versionId}/plans`) + .then(r => r.ok ? r.json() : []) + .then(setPlans) + .catch(() => setPlans([])) + } + + // Run one plan: the same call the Add Segment form makes, then stamp the log + // id back onto the plan so the list can tell run from unrun. Returns the + // error text, or null on success, so Run all can stop at the first failure + // rather than carrying on down a definition that has gone wrong. + async function runPlan(plan) { + const body = { + where_clause: plan.raw_where || buildFilterClause(normalizeFilters(plan.filters) || []), + note: plan.note || '', + date_offset: plan.date_offset || '0 days', + label: plan.label || '', + bucket: plan.bucket || '', + ...(plan.raw_where ? { raw_where: plan.raw_where } : { filters: plan.filters }), + } + if (!body.where_clause) return 'No filters on this plan' + try { + const res = await fetch(`/api/versions/${versionId}/${plan.kind}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json() + if (!res.ok) return data.error || 'Failed' + const logId = data.log_id ?? data.id ?? null + if (logId) { + await fetch(`/api/plans/${plan.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ run_logid: logId }), + }) + } + return null + } catch (err) { return err.message } + } + + async function runOnePlan(plan) { + setSubmitting(true) + const err = await runPlan(plan) + setSubmitting(false) + loadLog(); loadPlans() + flash(err || `Loaded “${plan.label || plan.tag || plan.kind}”`, err ? 'error' : 'ok') + } + + // In seq order, and stopping at the first failure: the segments of a + // definition are not independent -- a baseline followed by references that + // date-shift off it -- so carrying on past a broken one builds something + // nobody described. + async function runAllPlans() { + const pending = plans.filter(p => !p.run_logid) + if (!pending.length) return + setRunningAll(true) + let done = 0 + for (const plan of pending) { + const err = await runPlan(plan) + if (err) { + setRunningAll(false) + loadLog(); loadPlans() + flash(`Stopped after ${done} of ${pending.length}: ${err}`, 'error') + return + } + done++ + } + setRunningAll(false) + loadLog(); loadPlans() + flash(`Loaded ${done} segment${done === 1 ? '' : 's'}`) + } + + async function deletePlan(plan) { + await fetch(`/api/plans/${plan.id}`, { method: 'DELETE' }) + if (editingPlanId === plan.id) cancelEdit() + loadPlans() + } + + // A plan opens in the same form a segment does; the difference is only which + // call the submit makes. + function startEditPlan(plan) { + setSegType(plan.kind) + setSegNote(plan.note || '') + setSegLabel(plan.label || '') + setSegBucket(plan.bucket || '') + setOffset(plan.date_offset || '0 days') + const groups = normalizeFilters(plan.filters) + if (groups) { setUseRaw(false); setRawSql(''); setFilters(groups) } + else if (plan.raw_where) { setUseRaw(true); setRawSql(plan.raw_where) } + else { setUseRaw(false); setRawSql(''); setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } + setEditingLogId(null) + setEditingPlanId(plan.id) + setExpandedId(null) + setTimeout(() => { + document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 0) + } + + // Save the definition without running it — the whole point of a plan. + async function savePlan(opts = {}) { + const plan = plans.find(p => p.id === editingPlanId) + if (!plan) return null + setSubmitting(true) + try { + const res = await fetch(`/api/plans/${editingPlanId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: segType, + label: segLabel.trim(), + bucket: segBucket.trim(), + note: segNote.trim(), + date_offset: offset.trim() || '0 days', + ...(useRaw ? { raw_where: rawSql.trim(), filters: null } + : { filters, raw_where: null }), + }), + }) + const data = await res.json() + if (!res.ok) { flash(data.error, 'error'); return null } + loadPlans() + cancelEdit() + if (!opts.returnRow) flash('Plan saved — not loaded yet') + return data + } catch (err) { flash(err.message, 'error'); return null } + finally { setSubmitting(false) } + } + + // Save what is in the form, then run what was saved. Reading the plan back + // from the PATCH rather than reusing the row in state: the form is what the + // user just edited, and running the stale copy would load the old definition + // while showing the new one. + async function saveAndRunPlan() { + const saved = await savePlan({ returnRow: true }) + if (!saved) return + await runOnePlan(saved) + } + async function createVersion() { if (!newVerName.trim()) return setCreatingVer(true) @@ -203,7 +348,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const res = await fetch(`/api/sources/${sourceId}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, created_by: 'admin' }) + // created_by is ignored by the server, which takes the account from the + // session; sending it would let any client claim to be anyone. + body: JSON.stringify({ + name: newVerName.trim(), + description: newVerDesc, + ...(cloneFrom ? { clone_from: parseInt(cloneFrom) } : {}), + }) }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } @@ -212,7 +363,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio setShowNewVersion(false) setNewVerName('') setNewVerDesc('') - flash(`Version "${data.name}" created`) + setCloneFrom('') + flash(data.cloned + ? `Version "${data.name}" created from "${data.cloned.from_name}" — ${data.cloned.plans} segment${data.cloned.plans === 1 ? '' : 's'} planned, nothing loaded yet` + : `Version "${data.name}" created`) } catch (err) { flash(err.message, 'error') } finally { @@ -294,6 +448,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio function cancelEdit() { setEditingLogId(null) + setEditingPlanId(null) setShowAddForm(false) setSegNote('') setSegLabel('') @@ -357,9 +512,15 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio // 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) { + async function saveVersionName(field, value, opts = {}) { const next = value.trim() if (next === (selectedVersion?.[field] || '')) return + // The route COALESCEs a falsy name back to the existing one, so a blank box + // would look saved and change nothing. Say so instead. + if (field === 'name' && !next) { + flash('A version needs a name', 'error') + return + } try { const res = await fetch(`/api/versions/${versionId}`, { method: 'PUT', @@ -368,7 +529,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio }) 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') + // A rename shows everywhere the version is listed and needs no reload; a + // fallback name is baked into the aggregated row, so the pivot cannot + // re-derive it in place. + flash(opts.message || 'Saved — reload the Forecast view to see it') } catch (err) { flash(err.message, 'error') } } @@ -404,6 +568,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio )}
+ {/* Name and description. Nothing depends on the name -- the forecast table + is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the + UI's localStorage keys are id-based -- so it is safe to change at any + time, including on a closed version, where correcting a name is still + legitimate. Saves on blur, like the fallback names below. */} + {versionId && ( +
+ This forecast +
+ + saveVersionName('name', e.target.value, { message: 'Renamed' })} + onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }} + className="border border-gray-200 rounded px-2 py-1 text-sm w-64" /> +
+
+ + saveVersionName('description', e.target.value, { message: 'Saved' })} + onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }} + placeholder="optional" + className="border border-gray-200 rounded px-2 py-1 text-sm w-full" /> +
+
+ )} + {/* 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 @@ -436,6 +630,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" /> +
+ + +
setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" /> @@ -446,6 +648,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
+ {cloneFrom + ? <>Copies that version's settings, its saved layouts, and its load segments as plans — nothing is loaded, so the dates can be changed before anything runs. Adjustments are not copied: a scale's target was sized against the rows that were there. + : null} Creates a forecast table pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
@@ -453,6 +658,78 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {versionId && <> + {/* Planned segments — the definition, before it has been run. Only + shown when there are any: a version built by hand has none, and an + empty panel would be one more thing to read past. */} + {plans.length > 0 && ( +
+
+ Planned segments + + {plans.filter(p => !p.run_logid).length} of {plans.length} not yet loaded + +
+ +
+
+ + + + + + + + + + + + + + + {plans.map((p, i) => { + const where = p.raw_where + || buildFilterClause(normalizeFilters(p.filters) || []) + || '—' + return ( + + + + + + + + + + + ) + })} + +
#kindlabelcounts towardoffsetwhererows
{i + 1}{p.kind}{p.label || unnamed}{p.bucket || '—'}{p.date_offset}{where} + {p.run_logid ? (p.run_rows ?? '').toLocaleString?.() || p.run_rows : ''} + + {p.run_logid ? ( + loaded + ) : ( + <> + + + + + )} +
+
+ )} + {/* Segments loaded */}
@@ -586,10 +863,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
{/* Add / Edit Segment */} - {(showAddForm || editingLogId) && ( -
-
+ {(showAddForm || editingLogId || editingPlanId) && ( +
+
{(() => { + if (editingPlanId) { + const idx = plans.findIndex(p => p.id === editingPlanId) + const plan = plans[idx] + return plan + ? `Edit plan ${idx + 1} — ${plan.kind}${plan.label ? ` — ${plan.label}` : ''} — not loaded yet` + : 'Edit plan' + } if (!editingLogId) return 'Add Segment' const idx = log.findIndex(e => e.id === editingLogId) if (idx < 0) return 'Edit Segment' @@ -601,7 +885,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio : `Edit segment #${segNum} — ${label}` })()}
)} @@ -659,6 +946,12 @@ function SegmentForm({ onSubmit, submitting, editing, + // A plan saves without running, so its primary action is named for that and + // running is the secondary. Both null for an ordinary segment, where loading + // is the only thing submitting can mean. + submitLabel = null, + onSecondary = null, + secondaryLabel = null, }) { const disabled = mode === 'view' const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '') @@ -879,10 +1172,19 @@ function SegmentForm({ {mode === 'edit' && ( + )} + {mode === 'edit' && onSecondary && ( + )}
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 318f828..45cbb7a 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -664,10 +664,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } catch { setLogMeta({}) } } - async function refreshTags(sid) { - if (!sid) { setKnownTags([]); return } + async function refreshTags(vid) { + if (!vid) { setKnownTags([]); return } try { - const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json()) + const rows = await fetch(`/api/versions/${vid}/tags`).then(r => r.json()) setKnownTags(Array.isArray(rows) ? rows : []) } catch { setKnownTags([]) } } @@ -705,7 +705,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { refreshLogMeta(versionId) }, [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]) + useEffect(() => { refreshTags(versionId) }, [versionId]) // Stream an Arrow IPC endpoint into one buffer, reporting download progress. // Both /data and /agg speak the same protocol — a single record batch plus an @@ -1621,7 +1621,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // let the status bar re-read the forecast table's row count window.dispatchEvent(new CustomEvent('pf-data-changed')) refreshLogMeta(versionId) - if (opTag.trim()) refreshTags(sourceId) + if (opTag.trim()) refreshTags(versionId) if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) } if (op === 'recode') { setRecodeNote('') } if (op === 'clone') { setCloneNote(''); setCloneScale('1') } @@ -1849,7 +1849,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (field === 'tag') { // the ledger names its lines from this, and the tag list gains a new entry refreshLogMeta(versionId) - refreshTags(sourceId) + refreshTags(versionId) } } catch (err) { flash(err.message, 'error') @@ -1881,7 +1881,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, - opTag, setOpTag, knownTags, logMeta, + opTag, setOpTag, logMeta, scaleNote, setScaleNote, recodeSet, setRecodeSet, recodeNote, setRecodeNote, @@ -1898,10 +1898,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {/* Tag completions, shared by the operation panel and the change log's inline editor. Lives here so it is in the DOM even when the panel is shut. */} + {/* Value only. A datalist option's text content renders as a second + column beside the value, so the use count widened every row of the + pick list to carry a number nobody picks on. */} - {knownTags.map(t => ( - - ))} + {knownTags.map(t => {/* Toolbar */}