Merge branch 'clone-a-version-definition'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b1d6e5e627
107
CLAUDE.md
107
CLAUDE.md
@ -27,10 +27,11 @@ routes/
|
||||
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
|
||||
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
|
||||
sources.js Source registration, col_meta, SQL generation
|
||||
versions.js Version CRUD, baseline/reference load, data stream
|
||||
versions.js Version CRUD, baseline/reference load, data stream, tag completions
|
||||
operations.js scale, recode, clone, undo — the core forecast ops
|
||||
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
|
||||
layouts.js Named pivot layouts — list per version, create, patch, delete
|
||||
plans.js Planned load segments — list, create, patch, delete
|
||||
lib/
|
||||
sql_generator.js buildFilterClause, token substitution helpers
|
||||
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
|
||||
@ -65,6 +66,7 @@ ui/src/
|
||||
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it.
|
||||
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
|
||||
- **`pf.layout`** — named Perspective view configs; see §Pivot layouts
|
||||
- **`pf.load_plan`** — load segment definitions, before and apart from having been run; see §Cloning a version's definition
|
||||
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
|
||||
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
|
||||
- **`pf.session`** — express-session store (connect-pg-simple layout)
|
||||
@ -236,10 +238,18 @@ Both axes collapse the same way: a **depth in `ViewConfig`**, set through
|
||||
- **Columns** — `split_by_depth`, driven by the `Columns` buttons via
|
||||
`applySplitDepth()`
|
||||
|
||||
Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field
|
||||
list (`groupFull` / `splitFull`), so each button is named after the level it
|
||||
reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and
|
||||
offered levels a two-field `group_by` does not have.
|
||||
Both axes are drawn by `components/DepthButtons.jsx` from the axis's own field
|
||||
list (`groupFull` / `splitFull`), as a **stepper**: `◂ partgroup ▸`, with the
|
||||
middle button opening the full level list for a jump rather than a step.
|
||||
|
||||
Three controls per axis whatever the depth. It was one button per level, which
|
||||
grows with the pivot — a 5-by-3 layout rendered ten of them — and the current
|
||||
level was a blue button among the greys, so reading the state meant scanning.
|
||||
Before that the row axis was a fixed `0 1 2 3`, which named nothing and offered
|
||||
levels a two-field `group_by` does not have.
|
||||
|
||||
`depth` is null until the first layout has been read back; the stepper shows an
|
||||
em dash rather than guessing a level.
|
||||
|
||||
Both are **1-based**: they count the levels to show, where the imperative
|
||||
`view.set_depth()` counts the boundary below them. `server.cpp` does
|
||||
@ -321,6 +331,93 @@ together. `columns` selects which *measures* appear, not individual split
|
||||
combinations.
|
||||
|
||||
|
||||
## Tag completions are per version
|
||||
|
||||
`GET /versions/:id/tags` — the tags already used on **this** version, with their
|
||||
use counts, for completing the next adjustment. `Forecast.jsx` refetches on
|
||||
`versionId` and after every tagged write.
|
||||
|
||||
It used to be `GET /sources/:id/tags`, joined through `pf.version` and grouped
|
||||
across every version of the source. A tag names an initiative *inside* a
|
||||
scenario, so that put last year's work in front of this year's and reported a
|
||||
`uses` count earned on a version the tag did not belong to. The cost of the
|
||||
narrower scope is that a fresh version completes from nothing until it has a tag
|
||||
of its own — which is the honest state of it, since cloning a definition copies
|
||||
no adjustments.
|
||||
|
||||
The `pf_bucket` datalist on the Baseline page was already per version
|
||||
(`bucketsInUse`), so the two now agree.
|
||||
|
||||
**Completion is the datalist and nothing else.** The operation panel used to
|
||||
carry six quick-pick pills under the tag field as well. They wrapped to two rows
|
||||
as soon as the tags were longer than a word, and the panel docked at the bottom
|
||||
has a fixed height, so they pushed the form past its own border. The datalist
|
||||
costs no space, is not capped at six, and now shows the tag alone — an option's
|
||||
text content renders as a second column beside the value, so the use count was
|
||||
widening every row of the pick list to carry a number nobody picks on. `uses`
|
||||
and `last_used` still come back from the route; nothing reads them.
|
||||
|
||||
---
|
||||
|
||||
## Renaming a version
|
||||
|
||||
Safe at any time, including on a closed one. Nothing derives from the name: the
|
||||
forecast table is `fc_{tname}_{version_id}`, `pf.log` and `pf.layout` key on the
|
||||
id, and the UI's `localStorage` keys are id-based. The Baseline page's **This
|
||||
forecast** panel saves `name` and `description` on blur through
|
||||
`PUT /api/versions/:id`, which has accepted both all along — it was only ever
|
||||
called for the fallback names.
|
||||
|
||||
Two edges the panel handles: `(source_id, name)` is unique, so the route
|
||||
answers 409 on a collision rather than a raw Postgres error; and the `UPDATE`
|
||||
COALESCEs a falsy name back to the existing one, so a blank box is refused
|
||||
client-side rather than looking saved and changing nothing.
|
||||
|
||||
---
|
||||
|
||||
## Cloning a version's definition
|
||||
|
||||
`POST /sources/:id/versions` takes `clone_from`. It copies the template
|
||||
version's settings (`description`, `exclude_iters`, the three fallback names,
|
||||
`bucket_order`), its **version-scoped** layouts, and its load segments — and
|
||||
**runs nothing**. The reason to clone a definition is to change the dates before
|
||||
anything loads, and a clone that fetched two million rows first would have to be
|
||||
undone before it could be edited.
|
||||
|
||||
Not copied: adjustments, in any form. A scale's target is a figure sized against
|
||||
the rows that were there, so replaying one against a different load is
|
||||
arithmetic nobody asked for. Source-wide layouts (`version_id IS NULL`) are not
|
||||
copied either — they already apply to the new version, and a copy would
|
||||
duplicate the name.
|
||||
|
||||
### `pf.load_plan`
|
||||
|
||||
A segment used to exist only once it had been run: a `pf.log` entry with
|
||||
forecast rows behind it. A plan is the definition on its own — `kind`, `label`,
|
||||
`bucket`, `tag`, `note`, `filters`, `raw_where`, `date_offset`, `seq` — with
|
||||
`run_logid` naming the `pf.log` entry it produced, or null while it is still a
|
||||
plan.
|
||||
|
||||
**Deliberately not `pf.log`.** Every row there means a write happened; holding
|
||||
unexecuted intent in the audit log would cost it that meaning, and the change
|
||||
log would list entries that never ran.
|
||||
|
||||
`run_logid` is `ON DELETE SET NULL`, so undoing a load returns its plan to unrun
|
||||
rather than deleting the definition with it. The plans list reads the row count
|
||||
back through that FK, which is how it tells run from unrun.
|
||||
|
||||
**Derivation from an old version.** `cloneDefinition()` in `routes/versions.js`
|
||||
reads `pf.load_plan` when the template has plans and falls back to deriving them
|
||||
from its `baseline`/`reference` log entries when it does not — which is every
|
||||
version built before plans existed. `pf.log.params` already stores `filters`,
|
||||
`raw_where` and `date_offset`, so this is a read, not a reconstruction.
|
||||
|
||||
**Run all stops at the first failure.** The segments of a definition are not
|
||||
independent — a baseline followed by references that date-shift off it — so
|
||||
carrying on past a broken one builds something nobody described.
|
||||
|
||||
---
|
||||
|
||||
## Pivot layouts
|
||||
|
||||
A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an
|
||||
|
||||
125
routes/plans.js
Normal file
125
routes/plans.js
Normal file
@ -0,0 +1,125 @@
|
||||
const express = require('express');
|
||||
const { sessionUser } = require('../lib/auth');
|
||||
|
||||
// Planned load segments — a version's definition, before it has been run.
|
||||
//
|
||||
// A plan carries exactly what POST /versions/:id/{baseline|reference} takes, so
|
||||
// running one is that same call with the plan's fields; the client then stamps
|
||||
// run_logid. It is two round trips rather than one because the alternative is a
|
||||
// second copy of the load route's token substitution, and a plan that reads
|
||||
// unrun while its rows exist is visible and fixable, where a divergent copy of
|
||||
// the load SQL is neither.
|
||||
module.exports = function(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
const FIELDS = ['seq', 'kind', 'label', 'bucket', 'tag', 'note',
|
||||
'filters', 'raw_where', 'date_offset'];
|
||||
|
||||
// A plan whose log entry has been undone is a plan again: the FK is
|
||||
// ON DELETE SET NULL, so run_logid clears itself. The join is for the
|
||||
// row count, which is what makes "already run" worth seeing.
|
||||
const SELECT_PLANS = `
|
||||
SELECT
|
||||
p.*
|
||||
,l.row_count AS run_rows
|
||||
,l.stamp AS run_at
|
||||
FROM pf.load_plan p
|
||||
LEFT JOIN pf.log l
|
||||
ON l.id = p.run_logid
|
||||
WHERE TRUE
|
||||
AND p.version_id = $1
|
||||
ORDER BY
|
||||
p.seq
|
||||
,p.id`;
|
||||
|
||||
router.get('/versions/:id/plans', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(SELECT_PLANS, [parseInt(req.params.id)]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/versions/:id/plans', async (req, res) => {
|
||||
const versionId = parseInt(req.params.id);
|
||||
const b = req.body || {};
|
||||
if (!['baseline', 'reference'].includes(b.kind)) {
|
||||
return res.status(400).json({ error: 'kind must be baseline or reference' });
|
||||
}
|
||||
try {
|
||||
// Append by default: a new plan runs after the ones already there.
|
||||
const next = await pool.query(
|
||||
`SELECT COALESCE(max(seq), -1) + 1 AS seq FROM pf.load_plan WHERE version_id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pf.load_plan
|
||||
(version_id, seq, kind, label, bucket, tag, note, filters, raw_where,
|
||||
date_offset, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, '0 days'), $11)
|
||||
RETURNING *`,
|
||||
[versionId, b.seq ?? next.rows[0].seq, b.kind,
|
||||
b.label || null, b.bucket || null, b.tag || null, b.note || null,
|
||||
b.filters ? JSON.stringify(b.filters) : null, b.raw_where || null,
|
||||
b.date_offset || null, sessionUser(req)]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Any subset of the fields, plus run_logid when a run has just happened.
|
||||
// The flag-and-value pair rather than COALESCE on the value: clearing a
|
||||
// label to blank has to be distinguishable from not mentioning it.
|
||||
router.patch('/plans/:pid', async (req, res) => {
|
||||
const pid = parseInt(req.params.pid);
|
||||
const b = req.body || {};
|
||||
const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined);
|
||||
if (!keys.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
if (b.kind !== undefined && !['baseline', 'reference'].includes(b.kind)) {
|
||||
return res.status(400).json({ error: 'kind must be baseline or reference' });
|
||||
}
|
||||
try {
|
||||
const sets = [];
|
||||
const params = [pid];
|
||||
for (const k of keys) {
|
||||
let v = k === 'filters'
|
||||
? (b[k] === null ? null : JSON.stringify(b[k]))
|
||||
: (typeof b[k] === 'string' ? (b[k].trim() || null) : b[k]);
|
||||
// date_offset is NOT NULL, so a blank one means the default.
|
||||
// Coerced here rather than in the statement: a second assignment
|
||||
// to the same column is an error, not a fallback.
|
||||
if (k === 'date_offset' && !v) v = '0 days';
|
||||
params.push(v);
|
||||
sets.push(`${k} = $${params.length}${k === 'filters' ? '::jsonb' : ''}`);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pf.load_plan
|
||||
SET ${sets.join(', ')}, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
params
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Plan not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/plans/:pid', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Plan not found' });
|
||||
res.json({ deleted: rows[0].id });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@ -3,6 +3,74 @@ const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
|
||||
const { sessionUser, sessionTerritory } = require('../lib/auth');
|
||||
const { buildTerritoryClause } = require('../lib/sql_generator');
|
||||
|
||||
// Copy a version's definition onto a freshly created one: its load segments as
|
||||
// plans, and its saved layouts.
|
||||
//
|
||||
// The plans come from pf.load_plan when the template has them, and are derived
|
||||
// from its baseline/reference log entries when it does not — which is every
|
||||
// version created before plans existed, so a definition can be lifted out of
|
||||
// one that was built by hand. `params` on a load entry already stores the
|
||||
// filters, the raw WHERE when one was used, and the date offset, so the
|
||||
// derivation is a read rather than a reconstruction.
|
||||
async function cloneDefinition(client, template, version, created_by) {
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM pf.load_plan WHERE version_id = $1 ORDER BY seq, id`,
|
||||
[template.id]
|
||||
);
|
||||
|
||||
let source = existing.rows.map(p => ({
|
||||
kind: p.kind, label: p.label, bucket: p.bucket, tag: p.tag, note: p.note,
|
||||
filters: p.filters, raw_where: p.raw_where, date_offset: p.date_offset
|
||||
}));
|
||||
|
||||
if (!source.length) {
|
||||
const log = await client.query(
|
||||
`SELECT operation, label, bucket, tag, note, params
|
||||
FROM pf.log
|
||||
WHERE TRUE
|
||||
AND version_id = $1
|
||||
AND operation IN ('baseline', 'reference')
|
||||
ORDER BY id`,
|
||||
[template.id]
|
||||
);
|
||||
source = log.rows.map(e => ({
|
||||
kind: e.operation,
|
||||
label: e.label, bucket: e.bucket, tag: e.tag, note: e.note,
|
||||
filters: e.params?.filters || null,
|
||||
raw_where: e.params?.raw_where || null,
|
||||
date_offset: e.params?.date_offset || '0 days'
|
||||
}));
|
||||
}
|
||||
|
||||
for (const [i, p] of source.entries()) {
|
||||
await client.query(
|
||||
`INSERT INTO pf.load_plan
|
||||
(version_id, seq, kind, label, bucket, tag, note, filters, raw_where,
|
||||
date_offset, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, COALESCE($10, '0 days'), $11)`,
|
||||
[version.id, i, p.kind, p.label, p.bucket, p.tag, p.note,
|
||||
p.filters ? JSON.stringify(p.filters) : null, p.raw_where,
|
||||
p.date_offset, created_by]
|
||||
);
|
||||
}
|
||||
|
||||
// Version-scoped layouts only. The source-wide ones (version_id IS NULL)
|
||||
// already apply to the new version, so copying them would duplicate them
|
||||
// under a name that now exists twice.
|
||||
const layouts = await client.query(
|
||||
`INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default)
|
||||
SELECT source_id, $2, name, config, owner, visibility, is_default
|
||||
FROM pf.layout
|
||||
WHERE TRUE
|
||||
AND version_id = $1
|
||||
RETURNING id`,
|
||||
[template.id, version.id]
|
||||
);
|
||||
|
||||
return { from: template.id, from_name: template.name,
|
||||
plans: source.length, layouts: layouts.rowCount };
|
||||
}
|
||||
|
||||
module.exports = function(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
@ -24,7 +92,7 @@ module.exports = function(pool) {
|
||||
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
|
||||
router.post('/sources/:id/versions', async (req, res) => {
|
||||
const sourceId = parseInt(req.params.id);
|
||||
const { name, description, exclude_iters } = req.body;
|
||||
const { name, description, exclude_iters, clone_from } = req.body;
|
||||
const created_by = sessionUser(req);
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
|
||||
@ -62,19 +130,52 @@ module.exports = function(pool) {
|
||||
});
|
||||
}
|
||||
|
||||
// clone_from copies a version's *definition* -- its settings, its
|
||||
// saved layouts, and its load segments as plans -- and stops there.
|
||||
// The loads are deliberately not run: the reason to clone a
|
||||
// definition is to change the dates before it loads anything, and a
|
||||
// clone that fetched two million rows first would have to be undone
|
||||
// before it could be edited.
|
||||
//
|
||||
// Adjustments are not copied in any form. A scale's target is a
|
||||
// figure sized against the rows that were there, so replaying one
|
||||
// against a different load is arithmetic nobody asked for.
|
||||
let template = null;
|
||||
if (clone_from) {
|
||||
const t = await client.query(
|
||||
`SELECT * FROM pf.version WHERE id = $1 AND source_id = $2`,
|
||||
[parseInt(clone_from), sourceId]
|
||||
);
|
||||
if (!t.rows.length) {
|
||||
return res.status(404).json({
|
||||
error: 'Version to clone not found, or it belongs to a different source'
|
||||
});
|
||||
}
|
||||
template = t.rows[0];
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
// insert version to get id
|
||||
const verResult = await client.query(`
|
||||
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters,
|
||||
adjustment_segment, adjustment_bucket, unlabeled_load,
|
||||
bucket_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`, [
|
||||
sourceId,
|
||||
name,
|
||||
description || null,
|
||||
// An explicit description wins; otherwise the template's is
|
||||
// carried, since it describes the definition being copied.
|
||||
description || template?.description || null,
|
||||
created_by || null,
|
||||
exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]'
|
||||
exclude_iters ? JSON.stringify(exclude_iters)
|
||||
: (template ? JSON.stringify(template.exclude_iters) : '["reference"]'),
|
||||
template?.adjustment_segment || null,
|
||||
template?.adjustment_bucket || null,
|
||||
template?.unlabeled_load || null,
|
||||
template?.bucket_order ? JSON.stringify(template.bucket_order) : null
|
||||
]);
|
||||
const version = verResult.rows[0];
|
||||
|
||||
@ -109,8 +210,11 @@ ${colDefs},
|
||||
`CREATE INDEX ${table.split('.').pop()}_logid_idx ON ${table} (pf_logid)`
|
||||
);
|
||||
|
||||
let cloned = null;
|
||||
if (template) cloned = await cloneDefinition(client, template, version, created_by);
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ ...version, fc_table: table });
|
||||
res.status(201).json({ ...version, fc_table: table, cloned });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
@ -252,18 +356,29 @@ ${colDefs},
|
||||
// Tags already used on this source, newest first — feeds the tag autocomplete.
|
||||
// Scoped to the source rather than the version so an initiative name carries
|
||||
// across versions, which is the point of naming it.
|
||||
router.get('/sources/:id/tags', async (req, res) => {
|
||||
// Tags already used on one version, for completing the next adjustment.
|
||||
//
|
||||
// Scoped to the version, not the source. A tag names an initiative inside a
|
||||
// scenario -- what this plan is doing -- so offering every tag anyone has
|
||||
// ever used on the source put last year's work in the way of this year's,
|
||||
// and the `uses` count read as activity on a version it did not belong to.
|
||||
// The cost is that a fresh version completes from nothing until it has a
|
||||
// tag of its own, which is the honest state of it: cloning a definition
|
||||
// copies no adjustments, so it has no initiatives yet.
|
||||
router.get('/versions/:id/tags', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT l.tag,
|
||||
count(*)::int AS uses,
|
||||
max(l.stamp) AS last_used
|
||||
FROM pf.log l
|
||||
JOIN pf.version v ON v.id = l.version_id
|
||||
WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> ''
|
||||
WHERE TRUE
|
||||
AND l.version_id = $1
|
||||
AND l.tag IS NOT NULL
|
||||
AND l.tag <> ''
|
||||
GROUP BY l.tag
|
||||
ORDER BY max(l.stamp) DESC
|
||||
`, [req.params.id]);
|
||||
`, [parseInt(req.params.id)]);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -359,6 +474,13 @@ ${colDefs},
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// (source_id, name) is unique, so a rename can collide. The POST has
|
||||
// said so for as long as it has existed; this said it with a raw
|
||||
// Postgres message, which reads as a server fault rather than a name
|
||||
// that is already taken.
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A version with that name already exists for this source' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -81,6 +81,7 @@ app.use('/api', require('./routes/versions')(pool));
|
||||
app.use('/api', require('./routes/operations')(pool));
|
||||
app.use('/api', require('./routes/log')(pool));
|
||||
app.use('/api', require('./routes/layouts')(pool));
|
||||
app.use('/api', require('./routes/plans')(pool));
|
||||
|
||||
|
||||
const port = process.env.PORT || 3010;
|
||||
|
||||
@ -261,3 +261,37 @@ WHERE TRUE
|
||||
AND l.version_id IS NULL
|
||||
AND l.is_default
|
||||
);
|
||||
|
||||
-- pf.load_plan: a segment's definition, before and apart from having been run.
|
||||
--
|
||||
-- Until now a segment existed only once it had been loaded -- as a pf.log entry
|
||||
-- with forecast rows behind it -- so cloning a version's definition meant
|
||||
-- re-running every load, and there was nowhere to hold "this is the cut I want,
|
||||
-- but the dates need changing first".
|
||||
--
|
||||
-- Deliberately not pf.log. That table is the audit log: every row in it means a
|
||||
-- write happened, and putting unexecuted intent there would cost it that
|
||||
-- meaning. A plan is intent; run_logid is the pf.log entry it produced.
|
||||
CREATE TABLE IF NOT EXISTS pf.load_plan (
|
||||
id serial PRIMARY KEY,
|
||||
version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE,
|
||||
seq integer NOT NULL DEFAULT 0, -- run order
|
||||
kind text NOT NULL, -- baseline | reference
|
||||
label text,
|
||||
bucket text,
|
||||
tag text,
|
||||
note text,
|
||||
filters jsonb, -- the structured filter builder's own shape
|
||||
raw_where text, -- the raw-SQL escape hatch, when used instead
|
||||
date_offset text NOT NULL DEFAULT '0 days',
|
||||
-- the pf.log entry this plan produced, or null while it is still a plan.
|
||||
-- ON DELETE SET NULL so undoing a load returns its plan to unrun rather than
|
||||
-- deleting the definition with it.
|
||||
run_logid bigint REFERENCES pf.log(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by text,
|
||||
CHECK (kind IN ('baseline', 'reference'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS load_plan_version ON pf.load_plan (version_id, seq, id);
|
||||
|
||||
@ -1,26 +1,79 @@
|
||||
// One axis's collapse control. Rows and columns are the same thing — a depth in
|
||||
// ViewConfig, counting the levels to show — so they are drawn by one component
|
||||
// rather than two that happen to look alike.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// One axis's collapse control, as a stepper.
|
||||
//
|
||||
// The buttons are named after the level they reveal. The row axis used to be a
|
||||
// fixed 0 1 2 3: a number means nothing without already knowing what group_by
|
||||
// holds, and a fixed range offers levels that do not exist while hiding ones
|
||||
// that do.
|
||||
// Rows and columns are the same thing — a depth in ViewConfig, counting the
|
||||
// levels to show — so one component draws both.
|
||||
//
|
||||
// It used to be one button per level, which is N+1 controls that grow with the
|
||||
// pivot: a 5-by-3 layout rendered ten of them, and the active level was a blue
|
||||
// button somewhere among the greys, so reading the current state meant scanning.
|
||||
// A stepper is three controls whatever the depth, and says where you are rather
|
||||
// than making you find it. The full list is still one click away, for jumping
|
||||
// rather than stepping.
|
||||
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
// Capture phase: the pivot lives in a shadow root and retargets its events,
|
||||
// so a bubbled listener sees the host rather than the real target.
|
||||
document.addEventListener('mousedown', onDown, true)
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown, true)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const max = levels.length
|
||||
// depth is null until the first layout has been read back. Showing the last
|
||||
// level would be a guess; an em dash is honest and the arrows still work.
|
||||
const known = depth != null
|
||||
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
|
||||
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
|
||||
|
||||
const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' +
|
||||
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="pf-section-label">{label}</span>
|
||||
{Array.from({ length: levels.length + 1 }, (_, n) => (
|
||||
<button key={n} onClick={() => onPick(n)}
|
||||
title={n === 0
|
||||
? `Collapse ${label.toLowerCase()} to a single total`
|
||||
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' › ')}`}
|
||||
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
|
||||
${depth === n ? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||
{n === 0 ? totalLabel : levels[n - 1]}
|
||||
</button>
|
||||
))}
|
||||
<div ref={boxRef} className="relative flex items-center gap-1">
|
||||
<span className="pf-section-label mr-0.5">{label}</span>
|
||||
|
||||
<button className={arrow} disabled={!known || depth <= 0}
|
||||
title={`Collapse ${label.toLowerCase()} by one level`}
|
||||
onClick={() => step((depth ?? 0) - 1)}>◂</button>
|
||||
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
title={`${label}: showing ${current}`}
|
||||
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
|
||||
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||
{current}
|
||||
</button>
|
||||
|
||||
<button className={arrow} disabled={!known || depth >= max}
|
||||
title={`Expand ${label.toLowerCase()} by one level`}
|
||||
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
|
||||
border-gray-200 rounded shadow-lg py-1 text-xs">
|
||||
{Array.from({ length: max + 1 }, (_, n) => (
|
||||
<button key={n} onClick={() => { onPick(n); setOpen(false) }}
|
||||
title={n === 0
|
||||
? `Collapse ${label.toLowerCase()} to a single total`
|
||||
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' › ')}`}
|
||||
className={`block w-full text-left px-3 py-1 truncate
|
||||
${depth === n ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}>
|
||||
{n === 0 ? totalLabel : levels[n - 1]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -258,6 +258,12 @@ function derive(current, edit, dp = 2) {
|
||||
new: fmtNum(next, dp),
|
||||
change: fmtNum(change, dp),
|
||||
pct: pct == null ? '—' : fmtNum(pct, 1),
|
||||
// The same three as numbers, for anything that needs to compute with them.
|
||||
// The strings above are grouped by toLocaleString, and parseFloat stops at
|
||||
// the first separator -- "3,456,789" reads back as 3 -- so a caller that
|
||||
// re-parsed the display got a plausible small number rather than an error.
|
||||
// Nothing should have to know that; the numbers travel alongside.
|
||||
n: { new: next, change, pct },
|
||||
}
|
||||
out[edit.field] = edit.raw // keep what you typed exactly as typed
|
||||
return out
|
||||
@ -283,7 +289,7 @@ function groupDigits(raw) {
|
||||
return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}`
|
||||
}
|
||||
|
||||
function LedgerInput({ value, active, onChange, onFocus, suffix }) {
|
||||
function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix }) {
|
||||
const ref = useRef(null)
|
||||
const caret = useRef(null)
|
||||
const display = groupDigits(value)
|
||||
@ -305,8 +311,13 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) {
|
||||
try { el.setSelectionRange(pos, pos) } catch {}
|
||||
}, [display])
|
||||
|
||||
// The suffix is positioned inside the box rather than set beside it. As a
|
||||
// sibling in the flex row it took width of its own, so in a right-aligned cell
|
||||
// the % row's input sat a suffix-and-a-gap to the left of the rows above it,
|
||||
// and the three boxes that are meant to be three ways of saying one thing did
|
||||
// not line up. Inside, every row's box is the same width and the same edge.
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="relative inline-flex items-center">
|
||||
<input
|
||||
ref={ref}
|
||||
type="text" inputMode="decimal" value={display}
|
||||
@ -317,9 +328,17 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) {
|
||||
onChange(el.value.replace(/,/g, ''))
|
||||
}}
|
||||
onFocus={onFocus} placeholder="—"
|
||||
className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums
|
||||
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />
|
||||
{suffix && <span className="text-gray-500" style={{ fontSize: '10px' }}>{suffix}</span>}
|
||||
onKeyDown={e => { if (e.key === 'Escape' && onClear) { e.preventDefault(); onClear(); e.target.blur() } }}
|
||||
title={held ? 'Held by the Absorbed by setting — type here to set it yourself' : undefined}
|
||||
className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums transition-colors
|
||||
${suffix ? 'pl-2 pr-5' : 'px-2'}
|
||||
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800'
|
||||
: held ? 'border-gray-100 bg-gray-50 text-gray-400'
|
||||
: 'border-gray-200 bg-white text-gray-700'}`} />
|
||||
{suffix && (
|
||||
<span className="absolute right-1.5 text-gray-400 pointer-events-none"
|
||||
style={{ fontSize: '10px' }}>{suffix}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -433,9 +452,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
// left units and price blank -- exactly the two numbers you need to see to
|
||||
// know whether you asked for a price change or a volume change.
|
||||
const numOf = (key, field) => {
|
||||
const raw = derived[key]?.[field]
|
||||
const n = parseFloat(raw)
|
||||
return isFinite(n) ? n : null
|
||||
const n = derived[key]?.n?.[field]
|
||||
return n != null && isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const outcome = (() => {
|
||||
@ -473,18 +491,56 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
}
|
||||
})()
|
||||
|
||||
// The same three readings for a measure nobody typed into, taken from where
|
||||
// the edit actually lands it. Scaling dollars with price holding moves qty,
|
||||
// and leaving that column blank until the Result row meant the number you had
|
||||
// just implied was the one thing not shown next to the one you typed.
|
||||
const backfill = (current, next, dp) => {
|
||||
if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' }
|
||||
const change = next - current
|
||||
const pct = current === 0 ? null : (change / Math.abs(current)) * 100
|
||||
return {
|
||||
new: fmtNum(next, dp),
|
||||
change: fmtNum(change, dp),
|
||||
pct: pct == null ? '—' : fmtNum(pct, 1),
|
||||
n: { new: next, change, pct },
|
||||
}
|
||||
}
|
||||
|
||||
// What the rows render. derived stays the edit-driven map -- it is what
|
||||
// outcome is computed from, so back-filling into it would feed the result
|
||||
// back into its own inputs.
|
||||
const shown = Object.fromEntries(measures.map(m => [
|
||||
m.key,
|
||||
(filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp),
|
||||
]))
|
||||
|
||||
const setEdit = (key, field, raw) =>
|
||||
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
|
||||
|
||||
// Hand a measure back to being derived. Emptying the box does the same thing
|
||||
// -- filled() is false either way -- but that is only obvious once you know
|
||||
// it, and clicking into a held column to look at it and then wanting out
|
||||
// again is the ordinary case, not the awkward one.
|
||||
const clearEdit = (key) =>
|
||||
setScaleInputs(prev => {
|
||||
if (!(key in prev)) return prev
|
||||
const { [key]: _drop, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
// focusing a different row hands that measure's edit to the focused row,
|
||||
// carrying across whatever it currently reads
|
||||
const focusRow = (key, field) =>
|
||||
setScaleInputs(prev => {
|
||||
const cur = prev[key]
|
||||
if (cur && cur.field === field) return prev
|
||||
const shown = derived[key]?.[field] ?? ''
|
||||
const raw = shown === '—' ? '' : String(shown).replace(/,/g, '')
|
||||
return { ...prev, [key]: { field, raw: cur ? raw : '' } }
|
||||
// Whatever the box reads is what you get, including a back-filled
|
||||
// number: clicking into an implied figure is how you pin it, which is
|
||||
// the difference between "units may move" and "units are this".
|
||||
const text = shown[key]?.[field] ?? ''
|
||||
const raw = text === '—' ? '' : String(text).replace(/,/g, '')
|
||||
return { ...prev, [key]: { field, raw } }
|
||||
})
|
||||
|
||||
// A running total across the walk, in the primary measure. The lines sum to
|
||||
@ -508,6 +564,14 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
})()
|
||||
const showRunning = !!valueCol && (lines.length + exclLines.length) > 1
|
||||
|
||||
// Which measure the plug holds still. Only meaningful while dollars are the
|
||||
// sole input -- that is the one case where price and volume are both unknown
|
||||
// and the toggle is answering for them. 'price' means price absorbs the
|
||||
// change, so qty holds; 'volume' means qty absorbs it, so price holds.
|
||||
const heldKey = (valueCol && unitsCol && dollarsOnly)
|
||||
? (scalePlug === 'price' ? 'units' : 'price')
|
||||
: null
|
||||
|
||||
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
|
||||
const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
|
||||
|
||||
@ -518,9 +582,18 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
|
||||
<th className="text-left font-normal pb-1 pr-3"></th>
|
||||
{measures.map(m => (
|
||||
<th key={m.key} className="text-right font-normal pb-1 px-2 whitespace-nowrap">
|
||||
<th key={m.key}
|
||||
className={`text-right font-normal pb-1 px-2 whitespace-nowrap
|
||||
${m.key === heldKey ? 'text-gray-400' : ''}`}>
|
||||
{m.label}
|
||||
{m.hint && <span className="text-gray-500"> · {m.hint}</span>}
|
||||
{m.key === heldKey
|
||||
? <span className="text-gray-400"> · holds</span>
|
||||
: m.hint && <span className="text-gray-500"> · {m.hint}</span>}
|
||||
{scaleInputs[m.key] && (
|
||||
<button onClick={() => clearEdit(m.key)}
|
||||
title={`Stop editing ${m.label} — let it follow from the others again`}
|
||||
className="ml-1 text-gray-300 hover:text-red-500 leading-none">✕</button>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{showRunning && (
|
||||
@ -633,10 +706,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
return (
|
||||
<td key={m.key} className="px-2 py-0.5 text-right">
|
||||
<LedgerInput
|
||||
value={derived[m.key]?.[field] ?? ''}
|
||||
value={shown[m.key]?.[field] ?? ''}
|
||||
active={active}
|
||||
held={m.key === heldKey}
|
||||
onChange={(raw) => setEdit(m.key, field, raw)}
|
||||
onFocus={() => focusRow(m.key, field)}
|
||||
onClear={() => clearEdit(m.key)}
|
||||
suffix={field === 'pct' ? '%' : null}
|
||||
/>
|
||||
</td>
|
||||
@ -666,53 +741,50 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
||||
{/* A dollar figure on its own does not say whether price or volume moved.
|
||||
Only ask once the edit is actually dollars-only -- naming units or price
|
||||
has already answered it, and the control would just be noise. */}
|
||||
{valueCol && unitsCol && dollarsOnly && (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-600 whitespace-nowrap">Absorbed by</span>
|
||||
<Segmented
|
||||
value={scalePlug}
|
||||
onChange={setScalePlug}
|
||||
options={[
|
||||
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
||||
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 leading-snug">
|
||||
{scalePlug === 'price'
|
||||
? `${unitsCol} holds; price moves to reach the number.`
|
||||
: `Price holds; ${unitsCol} scales with the dollars.`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Both of these are settings on the edit, so they use Field like every
|
||||
other one -- an aligned label column and a hint below the control.
|
||||
They used to hand-roll their own label row and hint, with two
|
||||
different type sizes and no shared label width, so the two stacked
|
||||
into a paragraph of loose words rather than reading as two settings. */}
|
||||
{(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && (
|
||||
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
|
||||
|
||||
{hasExcl && (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
|
||||
<Segmented
|
||||
value={onTotal ? 'selected' : 'adjustable'}
|
||||
onChange={setTargetBasis}
|
||||
options={[
|
||||
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
|
||||
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-600 leading-snug max-w-md">
|
||||
{onTotal
|
||||
? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.`
|
||||
: `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{valueCol && unitsCol && dollarsOnly && (
|
||||
<Field label="Absorbed by">
|
||||
<Segmented
|
||||
value={scalePlug}
|
||||
onChange={setScalePlug}
|
||||
options={[
|
||||
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
||||
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{perSlice && (
|
||||
<p className="text-gray-600 leading-snug mt-2 max-w-md">
|
||||
Applied to each slice separately — the figures above are combined totals,
|
||||
so each slice's own change will differ.
|
||||
</p>
|
||||
{hasExcl && (
|
||||
<Field label="Target on"
|
||||
hint={onTotal
|
||||
? `${exclName} can't change, so the adjustable rows absorb the difference. The pivot will show your target.`
|
||||
: `${exclName} ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}>
|
||||
<Segmented
|
||||
value={onTotal ? 'selected' : 'adjustable'}
|
||||
onChange={setTargetBasis}
|
||||
options={[
|
||||
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
|
||||
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{perSlice && (
|
||||
<Field label="Per slice"
|
||||
hint="The figures above are combined totals, so each slice's own change will differ.">
|
||||
<span className="text-gray-500">Applied to each slice separately</span>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -898,7 +970,7 @@ export default function OperationPanel({
|
||||
scaleInputs, setScaleInputs,
|
||||
scalePlug, setScalePlug,
|
||||
targetBasis, setTargetBasis,
|
||||
opTag, setOpTag, knownTags = [], logMeta = {},
|
||||
opTag, setOpTag, logMeta = {},
|
||||
scaleNote, setScaleNote,
|
||||
recodeSet, setRecodeSet,
|
||||
recodeNote, setRecodeNote,
|
||||
@ -1066,24 +1138,12 @@ export default function OperationPanel({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Under the tag field and inside the same column, so they read as
|
||||
values for it rather than as a row of unexplained buttons. */}
|
||||
{knownTags.length > 0 && (
|
||||
<Field label="">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{knownTags.slice(0, 6).map(t => (
|
||||
<button key={t.tag} onClick={() => setOpTag(t.tag)}
|
||||
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
|
||||
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
|
||||
opTag.trim() === t.tag
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
|
||||
{t.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{/* The six quick-pick pills that used to sit here are gone. They
|
||||
wrapped onto two rows once the tags were more than a word long,
|
||||
which is most of them, and the panel docked at the bottom has a
|
||||
fixed height to spend -- so they pushed the form past its own
|
||||
border. The same tags complete from the input's datalist, which
|
||||
costs no space and is not capped at six. */}
|
||||
|
||||
<Field label="note">
|
||||
<input value={note} onChange={e => setNote(e.target.value)}
|
||||
|
||||
@ -96,6 +96,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const [newVerName, setNewVerName] = useState('')
|
||||
const [newVerDesc, setNewVerDesc] = useState('')
|
||||
const [creatingVer, setCreatingVer] = useState(false)
|
||||
const [cloneFrom, setCloneFrom] = useState('')
|
||||
// Planned segments: a version's definition before it has been run. Separate
|
||||
// from `log`, which is what has actually happened.
|
||||
const [plans, setPlans] = useState([])
|
||||
const [editingPlanId, setEditingPlanId] = useState(null)
|
||||
const [runningAll, setRunningAll] = useState(false)
|
||||
|
||||
// segment form
|
||||
const [segType, setSegType] = useState('baseline')
|
||||
@ -130,8 +136,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
}, [sourceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) { setLog([]); return }
|
||||
if (!versionId) { setLog([]); setPlans([]); return }
|
||||
loadLog()
|
||||
loadPlans()
|
||||
}, [versionId])
|
||||
|
||||
// A segment's banner: what it counts toward, independent of pf_iter. Held
|
||||
@ -196,6 +203,144 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
})
|
||||
}
|
||||
|
||||
function loadPlans() {
|
||||
fetch(`/api/versions/${versionId}/plans`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(setPlans)
|
||||
.catch(() => setPlans([]))
|
||||
}
|
||||
|
||||
// Run one plan: the same call the Add Segment form makes, then stamp the log
|
||||
// id back onto the plan so the list can tell run from unrun. Returns the
|
||||
// error text, or null on success, so Run all can stop at the first failure
|
||||
// rather than carrying on down a definition that has gone wrong.
|
||||
async function runPlan(plan) {
|
||||
const body = {
|
||||
where_clause: plan.raw_where || buildFilterClause(normalizeFilters(plan.filters) || []),
|
||||
note: plan.note || '',
|
||||
date_offset: plan.date_offset || '0 days',
|
||||
label: plan.label || '',
|
||||
bucket: plan.bucket || '',
|
||||
...(plan.raw_where ? { raw_where: plan.raw_where } : { filters: plan.filters }),
|
||||
}
|
||||
if (!body.where_clause) return 'No filters on this plan'
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}/${plan.kind}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) return data.error || 'Failed'
|
||||
const logId = data.log_id ?? data.id ?? null
|
||||
if (logId) {
|
||||
await fetch(`/api/plans/${plan.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ run_logid: logId }),
|
||||
})
|
||||
}
|
||||
return null
|
||||
} catch (err) { return err.message }
|
||||
}
|
||||
|
||||
async function runOnePlan(plan) {
|
||||
setSubmitting(true)
|
||||
const err = await runPlan(plan)
|
||||
setSubmitting(false)
|
||||
loadLog(); loadPlans()
|
||||
flash(err || `Loaded “${plan.label || plan.tag || plan.kind}”`, err ? 'error' : 'ok')
|
||||
}
|
||||
|
||||
// In seq order, and stopping at the first failure: the segments of a
|
||||
// definition are not independent -- a baseline followed by references that
|
||||
// date-shift off it -- so carrying on past a broken one builds something
|
||||
// nobody described.
|
||||
async function runAllPlans() {
|
||||
const pending = plans.filter(p => !p.run_logid)
|
||||
if (!pending.length) return
|
||||
setRunningAll(true)
|
||||
let done = 0
|
||||
for (const plan of pending) {
|
||||
const err = await runPlan(plan)
|
||||
if (err) {
|
||||
setRunningAll(false)
|
||||
loadLog(); loadPlans()
|
||||
flash(`Stopped after ${done} of ${pending.length}: ${err}`, 'error')
|
||||
return
|
||||
}
|
||||
done++
|
||||
}
|
||||
setRunningAll(false)
|
||||
loadLog(); loadPlans()
|
||||
flash(`Loaded ${done} segment${done === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
async function deletePlan(plan) {
|
||||
await fetch(`/api/plans/${plan.id}`, { method: 'DELETE' })
|
||||
if (editingPlanId === plan.id) cancelEdit()
|
||||
loadPlans()
|
||||
}
|
||||
|
||||
// A plan opens in the same form a segment does; the difference is only which
|
||||
// call the submit makes.
|
||||
function startEditPlan(plan) {
|
||||
setSegType(plan.kind)
|
||||
setSegNote(plan.note || '')
|
||||
setSegLabel(plan.label || '')
|
||||
setSegBucket(plan.bucket || '')
|
||||
setOffset(plan.date_offset || '0 days')
|
||||
const groups = normalizeFilters(plan.filters)
|
||||
if (groups) { setUseRaw(false); setRawSql(''); setFilters(groups) }
|
||||
else if (plan.raw_where) { setUseRaw(true); setRawSql(plan.raw_where) }
|
||||
else { setUseRaw(false); setRawSql(''); setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) }
|
||||
setEditingLogId(null)
|
||||
setEditingPlanId(plan.id)
|
||||
setExpandedId(null)
|
||||
setTimeout(() => {
|
||||
document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Save the definition without running it — the whole point of a plan.
|
||||
async function savePlan(opts = {}) {
|
||||
const plan = plans.find(p => p.id === editingPlanId)
|
||||
if (!plan) return null
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/plans/${editingPlanId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: segType,
|
||||
label: segLabel.trim(),
|
||||
bucket: segBucket.trim(),
|
||||
note: segNote.trim(),
|
||||
date_offset: offset.trim() || '0 days',
|
||||
...(useRaw ? { raw_where: rawSql.trim(), filters: null }
|
||||
: { filters, raw_where: null }),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return null }
|
||||
loadPlans()
|
||||
cancelEdit()
|
||||
if (!opts.returnRow) flash('Plan saved — not loaded yet')
|
||||
return data
|
||||
} catch (err) { flash(err.message, 'error'); return null }
|
||||
finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
// Save what is in the form, then run what was saved. Reading the plan back
|
||||
// from the PATCH rather than reusing the row in state: the form is what the
|
||||
// user just edited, and running the stale copy would load the old definition
|
||||
// while showing the new one.
|
||||
async function saveAndRunPlan() {
|
||||
const saved = await savePlan({ returnRow: true })
|
||||
if (!saved) return
|
||||
await runOnePlan(saved)
|
||||
}
|
||||
|
||||
async function createVersion() {
|
||||
if (!newVerName.trim()) return
|
||||
setCreatingVer(true)
|
||||
@ -203,7 +348,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const res = await fetch(`/api/sources/${sourceId}/versions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, created_by: 'admin' })
|
||||
// created_by is ignored by the server, which takes the account from the
|
||||
// session; sending it would let any client claim to be anyone.
|
||||
body: JSON.stringify({
|
||||
name: newVerName.trim(),
|
||||
description: newVerDesc,
|
||||
...(cloneFrom ? { clone_from: parseInt(cloneFrom) } : {}),
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
@ -212,7 +363,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
setShowNewVersion(false)
|
||||
setNewVerName('')
|
||||
setNewVerDesc('')
|
||||
flash(`Version "${data.name}" created`)
|
||||
setCloneFrom('')
|
||||
flash(data.cloned
|
||||
? `Version "${data.name}" created from "${data.cloned.from_name}" — ${data.cloned.plans} segment${data.cloned.plans === 1 ? '' : 's'} planned, nothing loaded yet`
|
||||
: `Version "${data.name}" created`)
|
||||
} catch (err) {
|
||||
flash(err.message, 'error')
|
||||
} finally {
|
||||
@ -294,6 +448,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingLogId(null)
|
||||
setEditingPlanId(null)
|
||||
setShowAddForm(false)
|
||||
setSegNote('')
|
||||
setSegLabel('')
|
||||
@ -357,9 +512,15 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
// The names a row falls back to when nobody has named it. Blank means "use the
|
||||
// built-in", so these save on blur like the segment fields and an empty box is
|
||||
// a meaningful value rather than a missing one.
|
||||
async function saveVersionName(field, value) {
|
||||
async function saveVersionName(field, value, opts = {}) {
|
||||
const next = value.trim()
|
||||
if (next === (selectedVersion?.[field] || '')) return
|
||||
// The route COALESCEs a falsy name back to the existing one, so a blank box
|
||||
// would look saved and change nothing. Say so instead.
|
||||
if (field === 'name' && !next) {
|
||||
flash('A version needs a name', 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}`, {
|
||||
method: 'PUT',
|
||||
@ -368,7 +529,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
await refreshVersions(sourceId)
|
||||
flash('Saved — reload the Forecast view to see it')
|
||||
// A rename shows everywhere the version is listed and needs no reload; a
|
||||
// fallback name is baked into the aggregated row, so the pivot cannot
|
||||
// re-derive it in place.
|
||||
flash(opts.message || 'Saved — reload the Forecast view to see it')
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
}
|
||||
|
||||
@ -404,6 +568,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and description. Nothing depends on the name -- the forecast table
|
||||
is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the
|
||||
UI's localStorage keys are id-based -- so it is safe to change at any
|
||||
time, including on a closed version, where correcting a name is still
|
||||
legitimate. Saves on blur, like the fallback names below. */}
|
||||
{versionId && (
|
||||
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">This forecast</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Name</label>
|
||||
<input
|
||||
key={`name-${versionId}-${selectedVersion?.name || ''}`}
|
||||
defaultValue={selectedVersion?.name || ''}
|
||||
onBlur={e => saveVersionName('name', e.target.value, { message: 'Renamed' })}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-sm w-64" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-[16rem]">
|
||||
<label className="text-xs text-gray-500">Description</label>
|
||||
<input
|
||||
key={`desc-${versionId}-${selectedVersion?.description || ''}`}
|
||||
defaultValue={selectedVersion?.description || ''}
|
||||
onBlur={e => saveVersionName('description', e.target.value, { message: 'Saved' })}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder="optional"
|
||||
className="border border-gray-200 rounded px-2 py-1 text-sm w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback names. Not part of any segment -- they are what the pivot shows
|
||||
for rows nobody has named, so they belong to the version rather than to
|
||||
a log entry. Blank falls back to the built-in in sql_generator's
|
||||
@ -436,6 +630,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
<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" />
|
||||
</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">
|
||||
<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" />
|
||||
@ -446,6 +648,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>
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
@ -453,6 +658,78 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
|
||||
{versionId && <>
|
||||
|
||||
{/* Planned segments — the definition, before it has been run. Only
|
||||
shown when there are any: a version built by hand has none, and an
|
||||
empty panel would be one more thing to read past. */}
|
||||
{plans.length > 0 && (
|
||||
<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">
|
||||
<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 */}
|
||||
<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">
|
||||
@ -586,10 +863,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</div>
|
||||
|
||||
{/* Add / Edit Segment */}
|
||||
{(showAddForm || editingLogId) && (
|
||||
<div id="add-segment" className={`bg-white border rounded ${editingLogId ? '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'}`}>
|
||||
{(showAddForm || editingLogId || editingPlanId) && (
|
||||
<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 || editingPlanId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
|
||||
<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'
|
||||
const idx = log.findIndex(e => e.id === editingLogId)
|
||||
if (idx < 0) return 'Edit Segment'
|
||||
@ -601,7 +885,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
: `Edit segment #${segNum} — ${label}`
|
||||
})()}</span>
|
||||
<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>
|
||||
</div>
|
||||
<SegmentForm
|
||||
@ -615,9 +899,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
segLabel={segLabel} setSegLabel={setSegLabel}
|
||||
offset={offset} setOffset={setOffset}
|
||||
filterCols={filterCols}
|
||||
onSubmit={loadSegment}
|
||||
onSubmit={editingPlanId ? savePlan : loadSegment}
|
||||
submitting={submitting}
|
||||
editing={!!editingLogId}
|
||||
submitLabel={editingPlanId ? 'Save plan' : null}
|
||||
onSecondary={editingPlanId ? saveAndRunPlan : null}
|
||||
secondaryLabel="Save and run"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -659,6 +946,12 @@ function SegmentForm({
|
||||
onSubmit,
|
||||
submitting,
|
||||
editing,
|
||||
// A plan saves without running, so its primary action is named for that and
|
||||
// running is the secondary. Both null for an ordinary segment, where loading
|
||||
// is the only thing submitting can mean.
|
||||
submitLabel = null,
|
||||
onSecondary = null,
|
||||
secondaryLabel = null,
|
||||
}) {
|
||||
const disabled = mode === 'view'
|
||||
const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '')
|
||||
@ -879,10 +1172,19 @@ function SegmentForm({
|
||||
{mode === 'edit' && (
|
||||
<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
|
||||
? (editing ? 'Saving…' : 'Loading…')
|
||||
: (editing
|
||||
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
|
||||
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)}
|
||||
? (editing || submitLabel ? 'Saving…' : 'Loading…')
|
||||
: (submitLabel
|
||||
|| (editing
|
||||
? `Save ${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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -664,10 +664,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
} catch { setLogMeta({}) }
|
||||
}
|
||||
|
||||
async function refreshTags(sid) {
|
||||
if (!sid) { setKnownTags([]); return }
|
||||
async function refreshTags(vid) {
|
||||
if (!vid) { setKnownTags([]); return }
|
||||
try {
|
||||
const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json())
|
||||
const rows = await fetch(`/api/versions/${vid}/tags`).then(r => r.json())
|
||||
setKnownTags(Array.isArray(rows) ? rows : [])
|
||||
} catch { setKnownTags([]) }
|
||||
}
|
||||
@ -705,7 +705,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
useEffect(() => { refreshLogMeta(versionId) }, [versionId])
|
||||
// Relabelling on the Baseline page needs a reload to show here: the label is
|
||||
// part of the aggregated row, so the pivot cannot re-derive it in place.
|
||||
useEffect(() => { refreshTags(sourceId) }, [sourceId])
|
||||
useEffect(() => { refreshTags(versionId) }, [versionId])
|
||||
|
||||
// Stream an Arrow IPC endpoint into one buffer, reporting download progress.
|
||||
// Both /data and /agg speak the same protocol — a single record batch plus an
|
||||
@ -1621,7 +1621,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
// let the status bar re-read the forecast table's row count
|
||||
window.dispatchEvent(new CustomEvent('pf-data-changed'))
|
||||
refreshLogMeta(versionId)
|
||||
if (opTag.trim()) refreshTags(sourceId)
|
||||
if (opTag.trim()) refreshTags(versionId)
|
||||
if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
|
||||
if (op === 'recode') { setRecodeNote('') }
|
||||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||
@ -1849,7 +1849,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
if (field === 'tag') {
|
||||
// the ledger names its lines from this, and the tag list gains a new entry
|
||||
refreshLogMeta(versionId)
|
||||
refreshTags(sourceId)
|
||||
refreshTags(versionId)
|
||||
}
|
||||
} catch (err) {
|
||||
flash(err.message, 'error')
|
||||
@ -1881,7 +1881,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
scaleInputs, setScaleInputs,
|
||||
scalePlug, setScalePlug,
|
||||
targetBasis, setTargetBasis,
|
||||
opTag, setOpTag, knownTags, logMeta,
|
||||
opTag, setOpTag, logMeta,
|
||||
scaleNote, setScaleNote,
|
||||
recodeSet, setRecodeSet,
|
||||
recodeNote, setRecodeNote,
|
||||
@ -1898,10 +1898,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
|
||||
{/* Tag completions, shared by the operation panel and the change log's
|
||||
inline editor. Lives here so it is in the DOM even when the panel is shut. */}
|
||||
{/* Value only. A datalist option's text content renders as a second
|
||||
column beside the value, so the use count widened every row of the
|
||||
pick list to carry a number nobody picks on. */}
|
||||
<datalist id="pf-tag-options">
|
||||
{knownTags.map(t => (
|
||||
<option key={t.tag} value={t.tag}>{t.uses} use{t.uses === 1 ? '' : 's'}</option>
|
||||
))}
|
||||
{knownTags.map(t => <option key={t.tag} value={t.tag} />)}
|
||||
</datalist>
|
||||
|
||||
{/* Toolbar */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user