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:
parent
cb9f98f551
commit
b014f7815a
53
CLAUDE.md
53
CLAUDE.md
@ -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
184
routes/plans.js
Normal 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;
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}_<id></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}_<id></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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user