Merge branch 'clone-a-version-definition'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 23:15:31 -04:00
commit b1d6e5e627
9 changed files with 932 additions and 137 deletions

107
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

@ -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)}
<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={`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'}`}>
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>
)
}

View File

@ -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,10 +741,16 @@ 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. */}
{/* 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">
{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>
<Field label="Absorbed by">
<Segmented
value={scalePlug}
onChange={setScalePlug}
@ -678,19 +759,14 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
['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>
</Field>
)}
{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>
<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}
@ -699,20 +775,16 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
['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>
</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>
<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)}

View File

@ -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}_&lt;id&gt;</span> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
</div>
</div>
@ -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
? (editing || submitLabel ? 'Saving…' : 'Loading…')
: (submitLabel
|| (editing
? `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>
)}
</div>

View File

@ -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 */}