Clone a version's definition without running it

A segment only existed once it had been loaded -- a pf.log entry with
forecast rows behind it -- so there was nowhere to hold "this is the cut I
want, but the dates need changing first", and rebuilding a scenario meant
retyping every segment by hand.

pf.load_plan is the definition on its own, with run_logid naming the log
entry it produced once it has been run. Not in pf.log deliberately: every
row there means a write happened, and unexecuted intent would cost that
table its meaning. The FK is ON DELETE SET NULL, so undoing a load returns
its plan to unrun rather than taking the definition with it.

POST /sources/:id/versions now takes clone_from: settings, version-scoped
layouts, and the segments as plans, running none of them. Plans are read
from the template's own pf.load_plan rows, or derived from its
baseline/reference log entries when it has none -- every version built
before this -- since params already stores the filters and the offset.

Adjustments are not copied in any form. A scale's target was sized against
the rows that were there.

Shift years exists because cloning a definition forward a year is the
reason to clone one, and doing it by hand across eight segments is where
the mistakes come from. Filter values only, and only a bare 20xx: a month
reads "04 - Sep" and a part number that looks like a year must not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 10:23:35 -04:00
parent cb9f98f551
commit b014f7815a
6 changed files with 682 additions and 18 deletions

View File

@ -31,6 +31,7 @@ routes/
operations.js scale, recode, clone, undo — the core forecast ops operations.js scale, recode, clone, undo — the core forecast ops
log.js GET /api/versions/:id/log, DELETE /api/log/:logid log.js GET /api/versions/:id/log, DELETE /api/log/:logid
layouts.js Named pivot layouts — list per version, create, patch, delete layouts.js Named pivot layouts — list per version, create, patch, delete
plans.js Planned load segments — list, create, patch, delete, shift-years
lib/ lib/
sql_generator.js buildFilterClause, token substitution helpers sql_generator.js buildFilterClause, token substitution helpers
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI 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.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.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
- **`pf.layout`** — named Perspective view configs; see §Pivot layouts - **`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.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.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
- **`pf.session`** — express-session store (connect-pg-simple layout) - **`pf.session`** — express-session store (connect-pg-simple layout)
@ -321,6 +323,57 @@ together. `columns` selects which *measures* appear, not individual split
combinations. 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 ## Pivot layouts
A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an

184
routes/plans.js Normal file
View File

@ -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;
};

View File

@ -3,6 +3,74 @@ const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
const { sessionUser, sessionTerritory } = require('../lib/auth'); const { sessionUser, sessionTerritory } = require('../lib/auth');
const { buildTerritoryClause } = require('../lib/sql_generator'); 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) { module.exports = function(pool) {
const router = express.Router(); 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 // inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => { router.post('/sources/:id/versions', async (req, res) => {
const sourceId = parseInt(req.params.id); 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); const created_by = sessionUser(req);
if (!name) return res.status(400).json({ error: 'name is required' }); 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'); await client.query('BEGIN');
// insert version to get id // insert version to get id
const verResult = await client.query(` const verResult = await client.query(`
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters) INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters,
VALUES ($1, $2, $3, $4, $5) adjustment_segment, adjustment_bucket, unlabeled_load,
bucket_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING * RETURNING *
`, [ `, [
sourceId, sourceId,
name, 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, 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]; const version = verResult.rows[0];
@ -109,8 +210,11 @@ ${colDefs},
`CREATE INDEX ${table.split('.').pop()}_logid_idx ON ${table} (pf_logid)` `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'); await client.query('COMMIT');
res.status(201).json({ ...version, fc_table: table }); res.status(201).json({ ...version, fc_table: table, cloned });
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
console.error(err); console.error(err);

View File

@ -81,6 +81,7 @@ app.use('/api', require('./routes/versions')(pool));
app.use('/api', require('./routes/operations')(pool)); app.use('/api', require('./routes/operations')(pool));
app.use('/api', require('./routes/log')(pool)); app.use('/api', require('./routes/log')(pool));
app.use('/api', require('./routes/layouts')(pool)); app.use('/api', require('./routes/layouts')(pool));
app.use('/api', require('./routes/plans')(pool));
const port = process.env.PORT || 3010; const port = process.env.PORT || 3010;

View File

@ -261,3 +261,37 @@ WHERE TRUE
AND l.version_id IS NULL AND l.version_id IS NULL
AND l.is_default 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);

View File

@ -96,6 +96,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [newVerName, setNewVerName] = useState('') const [newVerName, setNewVerName] = useState('')
const [newVerDesc, setNewVerDesc] = useState('') const [newVerDesc, setNewVerDesc] = useState('')
const [creatingVer, setCreatingVer] = useState(false) 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 // segment form
const [segType, setSegType] = useState('baseline') const [segType, setSegType] = useState('baseline')
@ -130,8 +137,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}, [sourceId]) }, [sourceId])
useEffect(() => { useEffect(() => {
if (!versionId) { setLog([]); return } if (!versionId) { setLog([]); setPlans([]); return }
loadLog() loadLog()
loadPlans()
}, [versionId]) }, [versionId])
// A segment's banner: what it counts toward, independent of pf_iter. Held // 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() { async function createVersion() {
if (!newVerName.trim()) return if (!newVerName.trim()) return
setCreatingVer(true) setCreatingVer(true)
@ -203,7 +365,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const res = await fetch(`/api/sources/${sourceId}/versions`, { const res = await fetch(`/api/sources/${sourceId}/versions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }
@ -212,7 +380,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
setShowNewVersion(false) setShowNewVersion(false)
setNewVerName('') setNewVerName('')
setNewVerDesc('') 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) { } catch (err) {
flash(err.message, 'error') flash(err.message, 'error')
} finally { } finally {
@ -294,6 +465,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
function cancelEdit() { function cancelEdit() {
setEditingLogId(null) setEditingLogId(null)
setEditingPlanId(null)
setShowAddForm(false) setShowAddForm(false)
setSegNote('') setSegNote('')
setSegLabel('') setSegLabel('')
@ -436,6 +608,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<label className="text-xs text-gray-500">Name</label> <label className="text-xs text-gray-500">Name</label>
<input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" /> <input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Copy definition from</label>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className="border border-gray-200 rounded px-2 py-1 text-sm w-48">
<option value="">— start empty —</option>
{versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div className="flex flex-col gap-1 flex-1"> <div className="flex flex-col gap-1 flex-1">
<label className="text-xs text-gray-500">Description</label> <label className="text-xs text-gray-500">Description</label>
<input value={newVerDesc} onChange={e => setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" /> <input value={newVerDesc} onChange={e => 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
<button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button> <button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button>
</div> </div>
<div className="text-xs text-gray-400 border-t border-gray-100 pt-2"> <div className="text-xs text-gray-400 border-t border-gray-100 pt-2">
{cloneFrom
? <>Copies that version's settings, its saved layouts, and its load segments as <em>plans</em> — <strong>nothing is loaded</strong>, 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 <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_&lt;id&gt;</span> 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. Creates a forecast table <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_&lt;id&gt;</span> 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.
</div> </div>
</div> </div>
@ -453,6 +636,86 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
{versionId && <> {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 && (
<div className="bg-white border border-gray-200 rounded max-w-full">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500
uppercase tracking-wide flex items-center gap-3 flex-wrap">
<span>Planned segments</span>
<span className="normal-case font-normal text-gray-400">
{plans.filter(p => !p.run_logid).length} of {plans.length} not yet loaded
</span>
<div className="ml-auto flex items-center gap-3 normal-case font-normal">
<div className="flex items-center gap-1">
<span className="text-gray-400">Shift years</span>
<input value={shiftYears} onChange={e => setShiftYears(e.target.value)}
className="border border-gray-200 rounded px-1 py-0.5 w-12 text-center" />
<button onClick={shiftPlanYears}
title="Add this many years to every unrun plan's date offset, and to any bare four-digit year in its filter values"
className="text-blue-600 hover:text-blue-700">Apply</button>
</div>
<button onClick={runAllPlans}
disabled={runningAll || submitting || !plans.some(p => !p.run_logid)}
className="border border-blue-200 text-blue-600 hover:text-blue-700 rounded
px-2 py-0.5 disabled:opacity-40">
{runningAll ? 'Loading…' : 'Run all'}
</button>
</div>
</div>
<table className="text-xs">
<thead className="bg-gray-50">
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium w-20">kind</th>
<th className="px-3 py-1.5 font-medium">label</th>
<th className="px-3 py-1.5 font-medium">counts toward</th>
<th className="px-3 py-1.5 font-medium">offset</th>
<th className="px-3 py-1.5 font-medium">where</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th>
<th className="px-3 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody>
{plans.map((p, i) => {
const where = p.raw_where
|| buildFilterClause(normalizeFilters(p.filters) || [])
|| '—'
return (
<tr key={p.id}
className={`border-t border-gray-50 ${editingPlanId === p.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : ''}`}>
<td className="px-3 py-1.5 text-gray-400">{i + 1}</td>
<td className="px-3 py-1.5 text-gray-500">{p.kind}</td>
<td className="px-3 py-1.5">{p.label || <span className="text-gray-300 italic">unnamed</span>}</td>
<td className="px-3 py-1.5 text-gray-500">{p.bucket || '—'}</td>
<td className="px-3 py-1.5 font-mono text-gray-500">{p.date_offset}</td>
<td className="px-3 py-1.5 font-mono text-gray-500 max-w-md truncate" title={where}>{where}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-500">
{p.run_logid ? (p.run_rows ?? '').toLocaleString?.() || p.run_rows : ''}
</td>
<td className="px-3 py-1.5 whitespace-nowrap">
{p.run_logid ? (
<span className="text-green-600" title={`Loaded ${p.run_at ? new Date(p.run_at).toLocaleString() : ''}`}>loaded</span>
) : (
<>
<button onClick={() => runOnePlan(p)} disabled={submitting || runningAll}
className="text-blue-600 hover:text-blue-700 mr-3 disabled:opacity-40">Run</button>
<button onClick={() => startEditPlan(p)}
className="text-gray-400 hover:text-blue-600 mr-3">Edit</button>
<button onClick={() => deletePlan(p)}
className="text-gray-300 hover:text-red-500">✕</button>
</>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Segments loaded */} {/* Segments loaded */}
<div className="bg-white border border-gray-200 rounded max-w-full"> <div className="bg-white border border-gray-200 rounded max-w-full">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between"> <div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
@ -586,10 +849,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div> </div>
{/* Add / Edit Segment */} {/* Add / Edit Segment */}
{(showAddForm || editingLogId) && ( {(showAddForm || editingLogId || editingPlanId) && (
<div id="add-segment" className={`bg-white border rounded ${editingLogId ? 'border-amber-300' : 'border-gray-200'}`}> <div id="add-segment" className={`bg-white border rounded ${editingLogId || editingPlanId ? 'border-amber-300' : 'border-gray-200'}`}>
<div className={`px-3 py-2 border-b text-xs font-medium uppercase tracking-wide flex items-center justify-between ${editingLogId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}> <div className={`px-3 py-2 border-b text-xs font-medium uppercase tracking-wide flex items-center justify-between ${editingLogId || editingPlanId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
<span>{(() => { <span>{(() => {
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' if (!editingLogId) return 'Add Segment'
const idx = log.findIndex(e => e.id === editingLogId) const idx = log.findIndex(e => e.id === editingLogId)
if (idx < 0) return 'Edit Segment' if (idx < 0) return 'Edit Segment'
@ -601,7 +871,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
: `Edit segment #${segNum} — ${label}` : `Edit segment #${segNum} — ${label}`
})()}</span> })()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal"> <button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'} {editingLogId || editingPlanId ? 'Cancel edit' : 'Close'}
</button> </button>
</div> </div>
<SegmentForm <SegmentForm
@ -615,9 +885,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
segLabel={segLabel} setSegLabel={setSegLabel} segLabel={segLabel} setSegLabel={setSegLabel}
offset={offset} setOffset={setOffset} offset={offset} setOffset={setOffset}
filterCols={filterCols} filterCols={filterCols}
onSubmit={loadSegment} onSubmit={editingPlanId ? savePlan : loadSegment}
submitting={submitting} submitting={submitting}
editing={!!editingLogId} editing={!!editingLogId}
submitLabel={editingPlanId ? 'Save plan' : null}
onSecondary={editingPlanId ? saveAndRunPlan : null}
secondaryLabel="Save and run"
/> />
</div> </div>
)} )}
@ -659,6 +932,12 @@ function SegmentForm({
onSubmit, onSubmit,
submitting, submitting,
editing, 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 disabled = mode === 'view'
const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '') const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '')
@ -879,10 +1158,19 @@ function SegmentForm({
{mode === 'edit' && ( {mode === 'edit' && (
<button onClick={onSubmit} disabled={submitting || (!useRaw && filters.length === 0)} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0"> <button onClick={onSubmit} disabled={submitting || (!useRaw && filters.length === 0)} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{submitting {submitting
? (editing ? 'Saving…' : 'Loading…') ? (editing || submitLabel ? 'Saving…' : 'Loading…')
: (editing : (submitLabel
|| (editing
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}` ? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)} : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`))}
</button>
)}
{mode === 'edit' && onSecondary && (
<button onClick={onSecondary} disabled={submitting || (!useRaw && filters.length === 0)}
title="Save this plan and load it now"
className="border border-blue-200 text-blue-600 hover:text-blue-700 text-xs px-4 py-2
rounded disabled:opacity-50 shrink-0">
{secondaryLabel}
</button> </button>
)} )}
</div> </div>