diff --git a/CLAUDE.md b/CLAUDE.md index 9fdb323..24f77bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ routes/ 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, shift-years 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) @@ -321,6 +323,57 @@ together. `columns` selects which *measures* appear, not individual split combinations. +## 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. + +**`POST /versions/:id/plans/shift-years`** adds a whole number of years to every +*unrun* plan's `date_offset` and to any bare four-digit `20xx` in its filter +**values**. Values only, and only a bare year: a season reads `2027`, a month +reads `04 - Sep`, and a part number that happens to look like a year would be +corrupted by anything looser. Cloning a definition forward a year is the reason +plans exist, and doing it by hand across eight segments is where the mistakes +come from. + +**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..24384ba --- /dev/null +++ b/routes/plans.js @@ -0,0 +1,184 @@ +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 }); + } + }); + + // Shift every unrun plan's date_offset and any four-digit year in its filter + // values by a whole number of years. Cloning a definition forward is the + // reason plans exist, and doing it by hand across eight segments is where + // the mistakes come from. + // + // Years in filter *values* only, and only a bare four-digit 20xx: a season + // reads "2027", a month reads "04 - Sep", and a part number that happens to + // look like a year would be corrupted by anything looser. + router.post('/versions/:id/plans/shift-years', async (req, res) => { + const versionId = parseInt(req.params.id); + const years = parseInt(req.body?.years); + if (!Number.isInteger(years) || years === 0) { + return res.status(400).json({ error: 'years must be a non-zero whole number' }); + } + const bumpYear = (v) => (typeof v === 'string' && /^20\d{2}$/.test(v.trim())) + ? String(parseInt(v, 10) + years) + : v; + const bumpOffset = (off) => { + // '0 days' stays put: an offset of nothing is not a date to shift. + const m = /^\s*(-?\d+)\s+year/.exec(off || ''); + if (!m) return off; + return `${parseInt(m[1], 10) + years} years`; + }; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `SELECT * FROM pf.load_plan + WHERE version_id = $1 AND run_logid IS NULL + ORDER BY seq, id`, + [versionId] + ); + const out = []; + for (const p of rows) { + const filters = Array.isArray(p.filters) + ? p.filters.map(group => group.map(f => ({ + ...f, + values: Array.isArray(f.values) ? f.values.map(bumpYear) : f.values + }))) + : p.filters; + const upd = await client.query( + `UPDATE pf.load_plan + SET filters = $2::jsonb, date_offset = $3, updated_at = now() + WHERE id = $1 + RETURNING *`, + [p.id, filters ? JSON.stringify(filters) : null, bumpOffset(p.date_offset)] + ); + out.push(upd.rows[0]); + } + await client.query('COMMIT'); + res.json({ shifted: out.length, plans: out }); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + return router; +}; diff --git a/routes/versions.js b/routes/versions.js index 11e2652..e77a01d 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); 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/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 6e92d01..9fd6b35 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -96,6 +96,13 @@ 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) + const [shiftYears, setShiftYears] = useState('1') // segment form const [segType, setSegType] = useState('baseline') @@ -130,8 +137,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 +204,160 @@ 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 shiftPlanYears() { + const years = parseInt(shiftYears) + if (!Number.isInteger(years) || years === 0) { flash('Enter a whole number of years', 'error'); return } + try { + const res = await fetch(`/api/versions/${versionId}/plans/shift-years`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ years }), + }) + const data = await res.json() + if (!res.ok) { flash(data.error, 'error'); return } + loadPlans() + flash(`Shifted ${data.shifted} plan${data.shifted === 1 ? '' : 's'} by ${years > 0 ? '+' : ''}${years} year${Math.abs(years) === 1 ? '' : 's'}`) + } catch (err) { flash(err.message, 'error') } + } + + 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 +365,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 +380,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 +465,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio function cancelEdit() { setEditingLogId(null) + setEditingPlanId(null) setShowAddForm(false) setSegNote('') setSegLabel('') @@ -436,6 +608,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 +626,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 +636,86 @@ 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 + +
+
+ Shift years + setShiftYears(e.target.value)} + className="border border-gray-200 rounded px-1 py-0.5 w-12 text-center" /> + +
+ +
+
+ + + + + + + + + + + + + + + {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 +849,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 +871,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio : `Edit segment #${segNum} — ${label}` })()}
)} @@ -659,6 +932,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 +1158,19 @@ function SegmentForm({ {mode === 'edit' && ( + )} + {mode === 'edit' && onSecondary && ( + )}