Forecast operations could only act on one clicked row at a time, and the
panel that drove them separated the numbers you were reading from the
inputs that changed them. This reworks both, and adds initiative tags so
a version's history can be read as a bridge.
Operations
- Accept `slices` (array) alongside the legacy single `slice`, with
apply_mode 'prorate' (one pool) or 'each' (independent per slice).
- buildWhereAny() ORs the slices into one predicate. A union of slices
cannot be flattened into per-column IN lists without over-selecting,
and the result is parenthesised so the appended exclude clause does not
bind wrong.
- resolveIncrs() now resolves each measure independently: target, percent
or change amount per measure, so a target on value and a percent on
units can be submitted together. Replaces the single global `mode`.
- target_basis chooses what a target measures against: only the rows an
operation can write, or everything the pivot shows for the slice.
Excluded iters are visible in the grid but immovable, so a target set
against the visible total previously overshot by their contribution.
Two latent bugs surfaced by the above, both pre-existing:
- A slice naming no filterable column reduced to TRUE and applied the
operation to the entire version. Now rejected on all three operations.
- Prorating across a pool that nets to ~zero multiplies each row's share
by an exploding factor, sending rows to extreme opposite values to hit
the target. Refused when the net falls below 1% of gross.
Tags and the bridge
- pf.log gains a nullable `tag`, written by a follow-up UPDATE rather
than through the generated SQL: those templates are stored per source
in pf.sql, so a {{tag}} token would strand any source that had not
re-run "Generate SQL".
- Tag is editable after the fact in the change log, with completion from
tags already used on the source. PATCH branches on whether a field was
sent, so a tag can be cleared as well as set.
- BridgeView renders the walk from baseline to current as a waterfall,
one step per tag, scoped to the selection, the pivot's filters, or the
whole version. Computed from the loaded Perspective table so the
figures always reconcile with what is on screen; overlapping slices are
deduped by pf_id to match the OR semantics operations use.
- Colour is a polarity job, so it uses the validated diverging pair
(blue/red, CVD dE 21.6) with neutral anchors, not categorical hues.
Every bar is directly labelled and a table view is available.
Panel
- Extracted to OperationPanel; the scale form is one continuous ledger:
baseline, each adjustment, current, then New value / Change / % change
as three interchangeable editable rows. Typing in any one derives the
others, which removes the target/delta/percent mode toggle entirely.
- Dockable bottom, right, or floating (drag to move, grip to resize), and
closable via header, Esc, or the toolbar. Placement persists.
- Controls no longer stretch to the dock width, and text contrast now
clears WCAG AA against white throughout.
Also: the status bar names the physical table writes land in, with live
row counts; and the pivot's expand depth is re-applied when the tab
regains focus, since Perspective rebuilds its view on redraw and a
ROLLUP view with no depth set renders fully expanded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
337 lines
14 KiB
JavaScript
337 lines
14 KiB
JavaScript
const express = require('express');
|
|
const { fcTable, mapType } = require('../lib/utils');
|
|
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
// list versions for a source
|
|
router.get('/sources/:id/versions', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT * FROM pf.version WHERE source_id = $1 ORDER BY created_at DESC`,
|
|
[req.params.id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// create a new version
|
|
// 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, created_by, exclude_iters } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
// fetch source
|
|
const srcResult = await client.query(
|
|
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
|
|
);
|
|
if (srcResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Source not found' });
|
|
}
|
|
const source = srcResult.rows[0];
|
|
|
|
// fetch col_meta joined to information_schema for data types
|
|
const colResult = await client.query(`
|
|
SELECT
|
|
m.cname,
|
|
m.role,
|
|
m.opos,
|
|
i.data_type,
|
|
i.numeric_precision,
|
|
i.numeric_scale
|
|
FROM pf.col_meta m
|
|
JOIN information_schema.columns i
|
|
ON i.table_schema = $2
|
|
AND i.table_name = $3
|
|
AND i.column_name = m.cname
|
|
WHERE m.source_id = $1
|
|
AND m.role NOT IN ('ignore')
|
|
ORDER BY m.opos
|
|
`, [sourceId, source.schema, source.tname]);
|
|
|
|
if (colResult.rows.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'No usable columns in col_meta — configure roles before creating a version'
|
|
});
|
|
}
|
|
|
|
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)
|
|
RETURNING *
|
|
`, [
|
|
sourceId,
|
|
name,
|
|
description || null,
|
|
created_by || null,
|
|
exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]'
|
|
]);
|
|
const version = verResult.rows[0];
|
|
|
|
// build CREATE TABLE DDL using col_meta + mapped data types
|
|
const table = fcTable(source.tname, version.id);
|
|
const systemCols = new Set(['pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at']);
|
|
const colDefs = colResult.rows
|
|
.filter(c => !systemCols.has(c.cname))
|
|
.map(c => {
|
|
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
|
const quoted = `"${c.cname}"`;
|
|
return ` ${quoted.padEnd(26)}${pgType}`;
|
|
}).join(',\n');
|
|
|
|
const ddl = `
|
|
CREATE TABLE ${table} (
|
|
pf_id bigserial PRIMARY KEY,
|
|
${colDefs},
|
|
pf_iter text NOT NULL,
|
|
pf_logid bigint NOT NULL,
|
|
pf_user text,
|
|
pf_created_at timestamptz NOT NULL DEFAULT now()
|
|
)
|
|
`;
|
|
await client.query(ddl);
|
|
|
|
await client.query('COMMIT');
|
|
res.status(201).json({ ...version, fc_table: table });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
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 });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// where this version's writes actually land: the physical forecast table,
|
|
// its current row count, and the source table rows are read from.
|
|
// Surfaced in the status bar so the write target is never a mystery.
|
|
router.get('/versions/:id/table-info', async (req, res) => {
|
|
try {
|
|
const verResult = await pool.query(`
|
|
SELECT v.id, v.name, v.status, s.schema, s.tname
|
|
FROM pf.version v
|
|
JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [req.params.id]);
|
|
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
|
|
|
const v = verResult.rows[0];
|
|
const fc = fcTable(v.tname, v.id);
|
|
const [schema, table] = fc.split('.');
|
|
|
|
const existsResult = await pool.query(
|
|
`SELECT to_regclass($1) IS NOT NULL AS exists`, [fc]
|
|
);
|
|
const exists = existsResult.rows[0].exists;
|
|
|
|
let rows = null, byIter = [];
|
|
if (exists) {
|
|
const countResult = await pool.query(
|
|
`SELECT pf_iter, count(*)::int AS n FROM ${fc} GROUP BY pf_iter ORDER BY pf_iter`
|
|
);
|
|
byIter = countResult.rows;
|
|
rows = byIter.reduce((a, r) => a + r.n, 0);
|
|
}
|
|
|
|
res.json({
|
|
version_id: v.id,
|
|
version_name: v.name,
|
|
status: v.status,
|
|
source: `${v.schema}.${v.tname}`,
|
|
fc_table: fc,
|
|
fc_schema: schema,
|
|
fc_tname: table,
|
|
exists,
|
|
rows,
|
|
by_iter: byIter
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// 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) => {
|
|
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 <> ''
|
|
GROUP BY l.tag
|
|
ORDER BY max(l.stamp) DESC
|
|
`, [req.params.id]);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Bridge: how this version got from its baseline to where it stands, grouped by
|
|
// initiative. Amounts come from the version's own forecast table, so the figures
|
|
// reconcile with the pivot rather than being recomputed from the log's params.
|
|
router.get('/versions/:id/bridge', async (req, res) => {
|
|
try {
|
|
const verResult = await pool.query(`
|
|
SELECT v.id, v.exclude_iters, s.schema, s.tname, s.id AS source_id
|
|
FROM pf.version v JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [req.params.id]);
|
|
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
|
const v = verResult.rows[0];
|
|
const fc = fcTable(v.tname, v.id);
|
|
|
|
const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]);
|
|
if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] });
|
|
|
|
const colResult = await pool.query(
|
|
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]);
|
|
const valueCol = colResult.rows.find(c => c.role === 'value')?.cname;
|
|
const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname;
|
|
if (!valueCol) return res.status(400).json({ error: 'No value column configured' });
|
|
|
|
const excl = (v.exclude_iters || []).length
|
|
? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})`
|
|
: 'TRUE';
|
|
|
|
const result = await pool.query(`
|
|
SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)'
|
|
ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag,
|
|
bool_or(t.pf_iter = 'baseline') AS is_baseline,
|
|
count(DISTINCT l.id)::int AS entries,
|
|
count(*)::int AS row_count,
|
|
round(sum(t."${valueCol}")::numeric, 2) AS value
|
|
${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''}
|
|
FROM ${fc} t
|
|
LEFT JOIN pf.log l ON l.id = t.pf_logid
|
|
WHERE ${excl}
|
|
GROUP BY 1
|
|
ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id)
|
|
`);
|
|
res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// update version name, description, or exclude_iters
|
|
router.put('/versions/:id', async (req, res) => {
|
|
const { name, description, exclude_iters } = req.body;
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE pf.version SET
|
|
name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
exclude_iters = COALESCE($4, exclude_iters)
|
|
WHERE id = $1
|
|
RETURNING *
|
|
`, [
|
|
req.params.id,
|
|
name || null,
|
|
description || null,
|
|
exclude_iters ? JSON.stringify(exclude_iters) : null
|
|
]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// close a version — blocks further edits
|
|
router.post('/versions/:id/close', async (req, res) => {
|
|
const { pf_user } = req.body;
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE pf.version
|
|
SET status = 'closed', closed_at = now(), closed_by = $2
|
|
WHERE id = $1 AND status = 'open'
|
|
RETURNING *
|
|
`, [req.params.id, pf_user || null]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found or already closed' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// reopen a closed version
|
|
router.post('/versions/:id/reopen', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE pf.version
|
|
SET status = 'open', closed_at = NULL, closed_by = NULL
|
|
WHERE id = $1 AND status = 'closed'
|
|
RETURNING *
|
|
`, [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found or already open' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// delete a version — drops forecast table then deletes version record
|
|
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
|
|
router.delete('/versions/:id', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const client = await pool.connect();
|
|
try {
|
|
const verResult = await client.query(`
|
|
SELECT v.*, s.tname
|
|
FROM pf.version v
|
|
JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [versionId]);
|
|
if (verResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found' });
|
|
}
|
|
const { tname } = verResult.rows[0];
|
|
const table = fcTable(tname, versionId);
|
|
|
|
await client.query('BEGIN');
|
|
await client.query(`DROP TABLE IF EXISTS ${table}`);
|
|
await client.query(`DELETE FROM pf.version WHERE id = $1`, [versionId]);
|
|
await client.query('COMMIT');
|
|
|
|
res.json({ message: 'Version deleted', fc_table: table });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
return router;
|
|
};
|