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 (
-
)
}
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
@@ -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)}.`}
-
)}
)
@@ -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 && (
+
+ )}
+
{/* 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" />
+
+ {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
+
+
)}
@@ -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. */}
{/* Toolbar */}