Compare commits

...

10 Commits

Author SHA1 Message Date
39335bca75 Add per-source default Perspective layout
Forecast falls back to a saved per-source layout when no version-local
layout is cached, so new versions of a source open with a sensible pivot
without each user reconfiguring it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:31:23 -04:00
953ae2709f Reference offsets, edit visual cues, todo updates
Reference segments can now apply a date offset just like baselines.
SQL template gains the {{date_offset}} token; both POST /reference and
PUT baseline/:logid pass it through. Existing sources need to
regenerate SQL to pick up the new template — old stored reference SQL
ignores the token (preserving prior verbatim behavior). The Baseline
form drops the "dates land verbatim" hint and shows the offset
control for both segment types.

Editing a segment now color-codes the source row amber with a ring
and tints the form border + header amber so the active connection is
visually obvious. Header label reads "Edit segment #3 — baseline —
note" instead of just "#43" (the internal log id).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:08:49 -04:00
4fde752b54 Default forecast pivot to value column with pf_iter rows
New forecasts opened the pivot with all dimensions stacked as
group_by and the date column as split_by — wide and slow to read.
Open with just the value column showing and pf_iter as rows so the
first thing you see is iteration totals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:08:43 -04:00
408cb06150 Unify Baseline segment view/edit with filter groups and SQL override
The segment form is now one component rendered in either 'view' or
'edit' mode — the expanded segment row in the list and the
add/edit form below share the same layout, view mode just disables
the inputs. Edit and View are visually identical so toggling between
them feels like enabling fields, not switching tools.

Filters become groups (conditions AND-ed inside, groups OR-ed
between) with + AND condition and + Add OR group affordances. The
compiled WHERE renders live below the groups so you can see what's
being built. A "Switch to manual SQL" toggle flips to a textarea
seeded with the compiled clause; backend baseline POST/PUT and
reference POST accept raw_where alongside filters and store whichever
arrived in pf.log.params for round-tripping.

The Add form is hidden until you click "+ Add segment" at the
bottom of the segments table; Edit also opens it. Cancel/Close
returns the table to its compact state.

/versions/:id/log now also returns value_total, units_total, and the
column names so the segments table can show row count and value sum
inline (header uses the source's actual value column name).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 01:50:52 -04:00
74ff97400b Track todo with implementation notes
Annotates each item with the design choice or open question. Notes
where existing spec coverage already addresses items 3 and 4
(structured filter groups and raw_where escape hatch) and where the
remaining work is wiring vs. greenfield.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 01:31:52 -04:00
3a6062d723 Edit baseline/reference segments before forecast rollout
Adds PUT /versions/:id/baseline/:logid that, in one transaction, drops
the segment's rows and log entry and replays the baseline or reference
SQL with new params. The endpoint refuses (409) if any scale, recode,
or clone has been applied — those operations were calibrated against
the old totals and would silently misreconcile.

Baseline view gets an Edit button on each segment (hidden once
forecast operations exist), populating the form with the original
filters, offset, and note. Submit issues PUT in edit mode, POST
otherwise. POST baseline and POST reference now also persist the
structured filters in pf.log.params so edit can reload them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 01:31:49 -04:00
6a98c3f8fc Persist Perspective viewer state including column formatting
Save/restore went through both viewer and plugin, where the explicit
plugin.restore could stomp the column formatting the viewer had
already applied. Capture via viewer.save() alone (it includes
plugin_config) and restore via a single viewer.restore call with
edit_mode merged in. Added a perspective-config-update listener so
formatting, sort, and other in-place changes persist to the last-used
cache without an explicit Save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 01:01:21 -04:00
a5e59f823a Workbench: per-iteration totals and price-driven scaling
The slice panel was a single muted line; now it shows a breakdown
table — value, units, and derived price for baseline / scale / recode /
clone, with a bold total row when more than one iteration applies.
Numbers use full text contrast so the current state is legible at a
glance during adjustments. Scale gains a price input that holds units
constant and translates to a value-target call (target value =
new_price × current_units).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:44:14 -04:00
15ab5a3a13 Tag operation result rows with pf_note and pf_op
Forecast tables don't carry pf_note; it's joined from pf.log on /data
fetch. RETURNING * from scale/recode/clone INSERTs lacks those fields,
so rows appended via table.update arrived with pf_note null until a
reload re-ran the join. Inject pf_note and pf_op server-side from the
request before responding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:33:32 -04:00
8492557621 Stabilize Forecast viewer lifecycle
Reuse a single Perspective worker across version switches and delete
the previous table instead of terminating the worker — terminate was
returning a rejecting promise the sync try/catch missed, and each new
worker leaked WASM memory. applyLayout no longer leaks a view per call;
it reads schema directly from the table. An init id guards against
concurrent runs (StrictMode, rapid version switches) clobbering each
other, and a catch on "already exists" recovers via open_table+delete
when a stale table from a previous run is still hosted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:11:32 -04:00
7 changed files with 863 additions and 276 deletions

View File

@ -69,6 +69,9 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildReference() {
const referenceSelect = dataCols.map(c =>
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
).join(', ');
return `
WITH
ilog AS (
@ -78,7 +81,7 @@ ilog AS (
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
SELECT ${referenceSelect}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE {{filter_clause}}
RETURNING *

View File

@ -123,18 +123,23 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
const { where_clause, date_offset, pf_user, note } = req.body;
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (where_clause || '').trim() || 'TRUE';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
@ -147,6 +152,82 @@ module.exports = function(pool) {
}
});
// edit a baseline or reference segment in place — only allowed before any
// scale/recode/clone has been applied on this version, since those would
// have been calibrated against the old segment's totals.
router.put('/versions/:id/baseline/:logid', async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const client = await pool.connect();
try {
const logResult = await client.query(
`SELECT * FROM pf.log WHERE id = $1 AND version_id = $2`,
[logid, versionId]
);
if (logResult.rows.length === 0) {
return res.status(404).json({ error: 'Log entry not found' });
}
const oldLog = logResult.rows[0];
if (!['baseline', 'reference'].includes(oldLog.operation)) {
return res.status(400).json({ error: 'Only baseline or reference segments can be edited' });
}
const opsResult = await client.query(
`SELECT COUNT(*)::int AS n FROM pf.log
WHERE version_id = $1 AND operation IN ('scale', 'recode', 'clone')`,
[versionId]
);
if (opsResult.rows[0].n > 0) {
return res.status(409).json({
error: 'Cannot edit segments after forecast operations have been applied. Undo the operations first.'
});
}
const ctx = await getContext(versionId, oldLog.operation);
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
await client.query('BEGIN');
const delRows = await client.query(
`DELETE FROM ${ctx.table} WHERE pf_logid = $1 RETURNING pf_id`,
[logid]
);
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
const insResult = await client.query(sql);
await client.query('COMMIT');
res.json({
rows_deleted: delRows.rowCount,
pf_ids: delRows.rows.map(r => r.pf_id),
rows_affected: insResult.rows[0]?.rows_affected ?? 0
});
} catch (err) {
try { await client.query('ROLLBACK'); } catch {}
console.error(err);
res.status(err.status || 500).json({ error: err.message });
} finally {
client.release();
}
});
// delete all baseline rows and log entries for a version
router.delete('/versions/:id/baseline', async (req, res) => {
const versionId = parseInt(req.params.id);
@ -183,18 +264,25 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, pf_user, note } = req.body;
const filterClause = (where_clause || '').trim() || 'TRUE';
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ where_clause: filterClause })),
filter_clause: filterClause
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
const result = await runSQL(sql);
@ -254,7 +342,8 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json({ rows: result.rows, rows_affected: result.rows.length });
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
res.json({ rows, rows_affected: rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -289,7 +378,8 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json({ rows: result.rows, rows_affected: result.rows.length });
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -326,7 +416,8 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
res.json({ rows: result.rows, rows_affected: result.rows.length });
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -338,19 +429,36 @@ module.exports = function(pool) {
const versionId = parseInt(req.params.id);
try {
const verResult = await pool.query(
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
`SELECT v.*, 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`,
[versionId]
);
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
const table = fcTable(verResult.rows[0].tname, versionId);
const { tname, source_id } = verResult.rows[0];
const table = fcTable(tname, versionId);
const colMeta = await pool.query(
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`,
[source_id]
);
const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname;
const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname;
const aggCols = [
`count(f.pf_id)::int AS row_count`,
valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`,
unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total`
].join(', ');
const result = await pool.query(`
SELECT l.*, count(f.pf_id)::int AS row_count
SELECT l.*, ${aggCols},
$2::text AS value_col,
$3::text AS units_col
FROM pf.log l
LEFT JOIN ${table} f ON f.pf_logid = l.id
WHERE l.version_id = $1
GROUP BY l.id
ORDER BY l.id DESC
`, [versionId]);
`, [versionId, valueCol || null, unitsCol || null]);
res.json(result.rows);
} catch (err) {
console.error(err);

View File

@ -222,6 +222,24 @@ module.exports = function(pool) {
}
});
// set or clear the default Perspective layout for a source.
// Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
// Pass null or {} to clear.
router.put('/sources/:id/default-layout', async (req, res) => {
try {
const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null;
const result = await pool.query(
`UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`,
[layout, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// deregister a source — does not drop existing forecast tables
router.delete('/sources/:id', async (req, res) => {
try {

View File

@ -9,11 +9,15 @@ CREATE TABLE IF NOT EXISTS pf.source (
tname text NOT NULL,
label text,
status text NOT NULL DEFAULT 'active', -- active | archived
default_layout jsonb, -- Perspective view config used as the per-source default
created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
UNIQUE (schema, tname)
);
-- backfill column for existing installs
ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb;
CREATE TABLE IF NOT EXISTS pf.col_meta (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,

113
todo.md Normal file
View File

@ -0,0 +1,113 @@
- [ ] when you enter the forecast, be able to enter in a context so you dont have to open the whole thing (should show in status bar and be a filter for SQL and spi calls)
- [x] should be able to edit and revise forecase segments that constitute baseline or reference. if you edit, maybe a warning that your forecast values wont mean a lot, and have an option to delete them.
Notes: A baseline/reference segment is a `pf.log` row plus the
forecast rows it produced (joined by pf_logid). Editing has the
shape of a delete-then-replay: drop the rows by pf_logid, drop the
log entry, re-run the segment with the new params (offset, filter,
iter type), insert the new log entry. New endpoint:
`PUT /versions/:id/baseline/:logid` (and the same for reference).
UI: an Edit button on each segment in Baseline view, populating the
form with the original `params`.
Cascade warning: if any scale/recode/clone log entries exist *after*
this segment was added, those operations were calibrated against
the old totals and will no longer reconcile cleanly. Show a banner
like "3 forecast operations applied after this segment may be
invalidated. View / Delete / Continue." Probably want a CASCADE
option that deletes downstream forecast entries too, plus a plain
"edit only" option for the user who knows what they're doing.
Implementation order: API + cascade detection first (compare
pf.log.stamp ordering); UI second.
- [~] be able to copy an existing forecast and it's segments to adjust some parameters without having to start from scrath.
Notes: A version is the unit of copy. Need a `POST /versions/:id/copy`
endpoint that creates a new pf.version row with the same source/
col_meta, creates the new fc_<tname>_<id> table via the same DDL
path, and replays each pf.log entry's INSERT against the new table
(preserving stamp ordering). Each log entry gets re-inserted
pointing at the new version_id; the new pf_logid feeds the row
inserts. Notes/users come along.
UI: "Copy" button next to each version in Baseline. Copy modal
asks for a new name and optional description, then runs the API
call (likely 530s for a 350k-row version since every segment is
re-evaluated). Show progress.
Two design questions worth deciding up front:
- Copy as-of-now (re-fetch source data, so freshly-arrived rows
show up in baseline)? Or freeze (replay from existing forecast
rows, i.e. clone the forecast table directly)? Different
semantics, different SQL — pick one before building.
- Should the copy track its origin? A `parent_version_id` column
on pf.version makes "show me variants of FY2026 Plan" easy.
- [x] need the list of filters to have an and/or specification
Notes: Spec already covers this in `pf_spec.md:245``filters` is
an array of groups; conditions within a group are AND-ed, groups
OR-ed. Backend has `buildFilterClause` in
`lib/sql_generator.js:247` but it's not wired into the routes
(baseline currently takes raw `where_clause`). Wiring + UI is the
remaining work.
UI: each group is a card with a header ("Group 1", "Group 2 — OR"),
rows of `column / operator / values`, a `+ Add condition` link,
and a `+ Add OR group` button at the bottom. The Baseline view
already has a single-group filter builder; extend it to wrap the
current rows in a group container and allow adding more groups.
- [x] the filters should have the option to just write the WHERE clause SQL
Notes: Spec covers this too (`pf_spec.md:251`, `:454`) as the
`raw_where` admin-only escape hatch. The current baseline endpoint
*already* takes `where_clause` as a raw string — so the API is
effectively in "raw only" mode today; it's the structured side
that's missing. Two things to add:
- Once structured `filters` is wired in, gate `raw_where` behind
an admin check (`pf_user` in admin list — needs admin list
config) and reject 400 if both are sent.
- UI toggle: a "Switch to manual SQL" link in the Baseline filter
builder swaps the structured rows for a `<textarea>`; warning
banner: "Raw SQL is not validated. You are responsible for
correctness and security."
- [ ] load status bar is super jittery and the numbers wildly change
Notes: `setLoadProgress` fires per chunk in the body-stream reader
(Forecast.jsx:155161). On localhost or fast connections the
reader yields chunks in tight bursts and React re-renders the
overlay text on each — the visible bytes value flickers because
paints land out of order with respect to setState batching.
Fix: throttle to ~10 updates/sec. Either `if (now - lastUpdate >
100)` before `setLoadProgress`, or accumulate received bytes into
a ref and flush on `requestAnimationFrame`. Five-line change.
- [ ] default layout for the pivot should be sales_usd group by pending_rep, split by pf_iter
Notes: Default-layout logic lives in `initViewer` (Forecast.jsx
~line 240) and currently picks `group_by = first 2 dimensions`,
`split_by = date column`. `sales_usd` / `pending_rep` are
source-specific, so hardcoding them in the view would break for
any other source.
Two paths:
- **Quick**: hardcode for the current source. Cheap, but rots
the moment a second source comes along.
- **Right**: store a default-layout config on `pf.source` (e.g., a
JSON `default_view` column with `{ group_by, split_by, columns }`)
and let initViewer read it. Setup view gets a small "Default
pivot" editor — pick a value column, group_by columns, split_by
column.
Suggest the right version since the spec already implies
per-source customization in col_meta, and you're going to want
this for any future source you register. The col_meta path is
even tighter: extend col_meta with role flags like `default_value`,
`default_group`, `default_split` that initViewer reads.

View File

@ -3,44 +3,48 @@ import Timeline from '../components/Timeline.jsx'
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
function buildFilterClause(filters) {
if (!filters.length) return null
const parts = filters.map(f => {
const col = `"${f.col}"`
const op = f.op
if (op === 'IS NULL') return `${col} IS NULL`
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
if (op === 'BETWEEN') {
const [a, b] = f.values
function buildCondition(c) {
const col = `"${c.col}"`
if (c.op === 'IS NULL') return `${col} IS NULL`
if (c.op === 'IS NOT NULL') return `${col} IS NOT NULL`
if (c.op === 'BETWEEN') {
const [a, b] = c.values
if (!a || !b) return null
return `${col} BETWEEN '${a}' AND '${b}'`
}
if (op === 'IN' || op === 'NOT IN') {
const vals = f.values.join("','")
return `${col} ${op} ('${vals}')`
if (c.op === 'IN' || c.op === 'NOT IN') {
const v = (c.values[0] || '').split(',').map(s => s.trim()).filter(Boolean)
if (!v.length) return null
return `${col} ${c.op} ('${v.join("','")}')`
}
return `${col} ${op} '${f.values[0]}'`
})
return parts.join(' AND ')
if (!c.values[0]) return null
return `${col} ${c.op} '${c.values[0]}'`
}
function getDateRange(filters) {
for (const f of filters) {
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
return { from: f.values[0], to: f.values[1] }
}
if (f.op === '=' && f.values[0]) {
return { from: f.values[0], to: f.values[0] }
}
}
return null
function buildFilterClause(groups) {
if (!groups?.length) return null
const parts = groups
.map(g => g.map(buildCondition).filter(Boolean).join(' AND '))
.filter(s => s.length > 0)
.map(s => `(${s})`)
if (!parts.length) return null
return parts.join(' OR ')
}
function parseDateRangeFromClause(clause) {
if (!clause) return null
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
if (m) return { from: m[1], to: m[2] }
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
if (m2) return { from: m2[1], to: m2[2] }
return null
}
function getDateRange(groups) {
for (const g of groups || []) {
for (const c of g) {
if (c.op === 'BETWEEN' && c.values[0] && c.values[1]) return { from: c.values[0], to: c.values[1] }
if (c.op === '=' && c.values[0]) return { from: c.values[0], to: c.values[0] }
}
}
return null
}
@ -51,9 +55,18 @@ function parseOffset(offsetStr) {
return { yr, mo }
}
function emptyFilter(cols) {
function emptyCondition(cols) {
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
}
function emptyGroup(cols) { return [emptyCondition(cols)] }
function normalizeFilters(stored) {
// accept legacy flat shape and wrap as one group; fall back to a blank group
if (!Array.isArray(stored) || stored.length === 0) return null
if (Array.isArray(stored[0])) return stored
if (stored[0]?.col != null) return [stored]
return null
}
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
const [filterCols, setFilterCols] = useState([])
@ -65,14 +78,19 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [newVerDesc, setNewVerDesc] = useState('')
const [creatingVer, setCreatingVer] = useState(false)
// add segment form
// segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([])
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
const [useRaw, setUseRaw] = useState(false)
const [rawSql, setRawSql] = useState('')
const [offsetYr, setOffsetYr] = useState(0)
const [offsetMo, setOffsetMo] = useState(0)
const [segNote, setSegNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const [editingLogId, setEditingLogId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [hasForecastOps, setHasForecastOps] = useState(false)
const [expandedId, setExpandedId] = useState(null)
const [msg, setMsg] = useState(null)
@ -82,7 +100,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
setFilterCols(fc)
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
setFilters(fc.length > 0 ? [emptyGroup(fc)] : [])
})
}, [sourceId])
@ -94,6 +112,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
function loadLog() {
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation)))
})
}
@ -121,59 +140,37 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}
}
function addFilter() {
setFilters(f => [...f, emptyFilter(filterCols)])
}
function removeFilter(i) {
setFilters(f => f.filter((_, idx) => idx !== i))
}
function updateFilter(i, field, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
if (field === 'op') {
const needsTwo = value === 'BETWEEN'
const needsOne = ['=', '!='].includes(value)
const needsMany = ['IN', 'NOT IN'].includes(value)
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
}
return { ...row, [field]: value }
}))
}
function updateFilterValue(i, vi, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
const vals = [...row.values]
vals[vi] = value
return { ...row, values: vals }
}))
}
async function loadSegment() {
const clause = buildFilterClause(filters)
if (!clause) { flash('Add at least one filter', 'error'); return }
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
const body = isRef
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
}
setSubmitting(true)
try {
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
method: 'POST',
const url = editingLogId
? `/api/versions/${versionId}/baseline/${editingLogId}`
: `/api/versions/${versionId}/${endpoint}`
const method = editingLogId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
flash(editingLogId
? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}`
: `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
cancelEdit()
} catch (err) {
flash(err.message, 'error')
} finally {
@ -181,6 +178,52 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}
}
function startEdit(entry) {
if (hasForecastOps) {
flash('Undo forecast operations first to edit segments', 'error')
return
}
const params = entry.params || {}
setSegType(entry.operation)
setSegNote(entry.note || '')
setDescription('')
const off = parseOffset(params.date_offset)
setOffsetYr(off.yr)
setOffsetMo(off.mo)
const groups = normalizeFilters(params.filters)
if (groups) {
setUseRaw(false)
setRawSql('')
setFilters(groups)
} else if (params.where_clause) {
// legacy: only the compiled WHERE was stored. Open in raw mode.
setUseRaw(true)
setRawSql(params.where_clause)
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
} else {
setUseRaw(false)
setRawSql('')
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
}
setEditingLogId(entry.id)
setExpandedId(null)
setTimeout(() => {
document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 0)
}
function cancelEdit() {
setEditingLogId(null)
setShowAddForm(false)
setDescription('')
setSegNote('')
setOffsetYr(0)
setOffsetMo(0)
setUseRaw(false)
setRawSql('')
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
}
async function undoSegment(logid) {
await fetch(`/api/log/${logid}`, { method: 'DELETE' })
loadLog()
@ -230,14 +273,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
setTimeout(() => setMsg(null), 3000)
}
const dateRange = getDateRange(filters)
const selectedVersion = versions.find(v => String(v.id) === versionId)
return (
<div className="h-full overflow-y-auto bg-gray-50">
<div className="p-4 flex flex-col gap-4 max-w-4xl">
{/* Flash */}
{msg && (
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg.text}
@ -260,7 +301,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
)}
</div>
{/* New version inline form */}
{showNewVersion && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
<div className="flex items-end gap-3">
@ -297,6 +337,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<th className="px-3 py-1.5 font-medium w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th>
<th className="px-3 py-1.5 font-medium text-right">{log[0]?.value_col || 'value'}</th>
<th className="px-3 py-1.5 font-medium">by</th>
<th className="px-3 py-1.5 font-medium">when</th>
<th className="px-3 py-1.5 font-medium"></th>
@ -304,23 +346,31 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead>
<tbody>
{log.length === 0 && (
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
<tr><td colSpan={8} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{!showAddForm && !editingLogId && (
<tr className="border-t border-gray-100">
<td colSpan={8} className="p-0">
<button
onClick={() => setShowAddForm(true)}
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
>
+ Add segment
</button>
</td>
</tr>
)}
{log.map((entry, i) => {
const isOpen = expandedId === entry.id
const params = entry.params || {}
const dr = parseDateRangeFromClause(params.where_clause)
const off = parseOffset(params.date_offset)
const view = segmentValuesFor(entry, filterCols)
return (
<>
<tr
key={entry.id}
onClick={() => setExpandedId(isOpen ? null : entry.id)}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : isOpen ? 'bg-blue-50' : ''}`}
>
<td className="px-3 py-2 text-gray-400 w-6">
<span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span>
</td>
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
<td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
@ -328,29 +378,26 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</span>
{entry.note || <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2 text-right text-gray-700 font-mono">
{entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
</td>
<td className="px-3 py-2 text-right text-gray-700 font-mono">
{entry.value_total != null ? entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
</td>
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right">
{!hasForecastOps && (
<button onClick={e => { e.stopPropagation(); startEdit(entry) }} className="text-gray-400 hover:text-blue-600 text-xs mr-3">Edit</button>
)}
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
</td>
</tr>
{isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={6} className="px-4 py-3">
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
</div>
{dr && (
<div className="mt-1">
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
</div>
)}
<td colSpan={6} className="px-2 py-2">
<div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} />
</div>
</td>
</tr>
@ -362,86 +409,259 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</table>
</div>
{/* Add Segment */}
<div className="bg-white border border-gray-200 rounded">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
Add Segment
{/* 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'}`}>
<span>{(() => {
if (!editingLogId) return 'Add Segment'
const idx = log.findIndex(e => e.id === editingLogId)
if (idx < 0) return 'Edit Segment'
const entry = log[idx]
const segNum = log.length - idx
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
return entry.note
? `Edit segment #${segNum}${label}${entry.note}`
: `Edit segment #${segNum}${label}`
})()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'}
</button>
</div>
<SegmentForm
mode="edit"
segType={segType} setSegType={setSegType}
filters={filters} setFilters={setFilters}
useRaw={useRaw} setUseRaw={setUseRaw}
rawSql={rawSql} setRawSql={setRawSql}
description={description} setDescription={setDescription}
segNote={segNote} setSegNote={setSegNote}
offsetYr={offsetYr} setOffsetYr={setOffsetYr}
offsetMo={offsetMo} setOffsetMo={setOffsetMo}
filterCols={filterCols}
onSubmit={loadSegment}
submitting={submitting}
editing={!!editingLogId}
/>
</div>
)}
</>}
</div>
</div>
)
}
// derive view-mode props for a saved segment
function segmentValuesFor(entry, filterCols) {
const params = entry.params || {}
const off = parseOffset(params.date_offset)
const groups = normalizeFilters(params.filters)
return {
segType: entry.operation === 'reference' ? 'reference' : 'baseline',
filters: groups || (filterCols.length > 0 ? [emptyGroup(filterCols)] : []),
useRaw: !groups && !!params.where_clause,
rawSql: params.where_clause || '',
description: '',
segNote: entry.note || '',
offsetYr: off.yr,
offsetMo: off.mo,
}
}
function SegmentForm({
mode, // 'view' | 'edit'
segType, setSegType,
filters, setFilters,
useRaw, setUseRaw,
rawSql, setRawSql,
description, setDescription,
segNote, setSegNote,
offsetYr, setOffsetYr,
offsetMo, setOffsetMo,
filterCols,
onSubmit,
submitting,
editing,
}) {
const disabled = mode === 'view'
const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '')
const dateRange = useRaw ? parseDateRangeFromClause(rawSql) : getDateRange(filters)
function setGroup(gi, fn) {
setFilters(prev => prev.map((g, i) => i === gi ? fn(g) : g))
}
function addCondition(gi) { setGroup(gi, g => [...g, emptyCondition(filterCols)]) }
function removeCondition(gi, ci) {
setFilters(prev => {
const next = prev.map((g, i) => i === gi ? g.filter((_, j) => j !== ci) : g)
return next.filter(g => g.length > 0)
})
}
function updateCondition(gi, ci, field, value) {
setGroup(gi, g => g.map((c, j) => {
if (j !== ci) return c
if (field === 'op') {
const needsTwo = value === 'BETWEEN'
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
return { ...c, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : [''] }
}
return { ...c, [field]: value }
}))
}
function updateConditionValue(gi, ci, vi, value) {
setGroup(gi, g => g.map((c, j) => {
if (j !== ci) return c
const vals = [...c.values]; vals[vi] = value
return { ...c, values: vals }
}))
}
function addGroup() { setFilters(prev => [...prev, emptyGroup(filterCols)]) }
function removeGroup(gi) { setFilters(prev => prev.filter((_, i) => i !== gi)) }
function toggleRaw() {
if (useRaw) {
setUseRaw(false)
setRawSql('')
} else {
setRawSql(compiled)
setUseRaw(true)
}
}
const baseInp = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white disabled:bg-gray-50 disabled:text-gray-500'
return (
<div className="p-4 flex flex-col gap-4">
{/* Type toggle */}
{/* Type */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{['baseline', 'reference'].map(t => (
<button
key={t}
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
disabled={disabled}
onClick={() => { if (disabled) return; setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'} disabled:opacity-60 disabled:cursor-default`}
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description */}
{/* Description (edit only) */}
{mode === 'edit' && (
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
</div>
)}
{/* Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
{!disabled && (
<button onClick={toggleRaw} className="text-blue-600 hover:text-blue-700 text-xs font-medium">
{useRaw ? '← Back to filters' : 'Switch to manual SQL →'}
</button>
)}
</div>
<div className="flex flex-col gap-1.5 ml-28">
{filters.map((f, i) => {
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
{!useRaw && (
<div className="flex flex-col gap-3 ml-28">
{filters.map((group, gi) => (
<div key={gi} className="border border-gray-200 rounded">
<div className="flex items-center justify-between px-2 py-1 bg-gray-50 border-b border-gray-100">
<span className="text-xs text-gray-500">
{gi === 0 ? 'Group 1' : `Group ${gi + 1} — OR`}
</span>
{!disabled && (
<div className="flex items-center gap-3">
<button onClick={() => addCondition(gi)} className="text-blue-600 hover:text-blue-700 text-xs">+ AND condition</button>
{filters.length > 1 && (
<button onClick={() => removeGroup(gi)} className="text-gray-300 hover:text-red-400 text-xs">remove group</button>
)}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 p-2">
{group.map((c, ci) => {
const isDateCol = filterCols.find(fc => fc.cname === c.col)?.role === 'date'
return (
<div key={i} className="flex items-center gap-2 flex-wrap">
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
<div key={ci} className="flex items-center gap-2 flex-wrap">
<select disabled={disabled} value={c.col} onChange={e => updateCondition(gi, ci, 'col', e.target.value)} className={baseInp}>
{filterCols.map(fc => <option key={fc.cname} value={fc.cname}>{fc.cname}</option>)}
</select>
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
<select disabled={disabled} value={c.op} onChange={e => updateCondition(gi, ci, 'op', e.target.value)} className={baseInp}>
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
{f.op === 'BETWEEN' && <>
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
{c.op === 'BETWEEN' && <>
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="from" className={`${baseInp} w-36 font-mono`} />
<span className="text-gray-400 text-xs">and</span>
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[1] || ''} onChange={e => updateConditionValue(gi, ci, 1, e.target.value)} placeholder="to" className={`${baseInp} w-36 font-mono`} />
</>}
{(f.op === '=' || f.op === '!=') && (
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
{(c.op === '=' || c.op === '!=') && (
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="value" className={`${baseInp} w-36 font-mono`} />
)}
{(f.op === 'IN' || f.op === 'NOT IN') && (
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
{(c.op === 'IN' || c.op === 'NOT IN') && (
<input disabled={disabled} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="val1, val2, …" className={`${baseInp} w-48 font-mono`} />
)}
{!disabled && group.length > 1 && (
<button onClick={() => removeCondition(gi, ci)} className="text-gray-300 hover:text-red-400 text-xs"></button>
)}
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
)
})}
</div>
</div>
))}
{!disabled && (
<button onClick={addGroup} className="self-start text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add OR group</button>
)}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
)}
{useRaw && (
<div className="ml-28">
<textarea
disabled={disabled}
value={rawSql}
onChange={e => setRawSql(e.target.value)}
placeholder="WHERE clause body (no WHERE keyword) — e.g. (status = 'OPEN' AND order_date BETWEEN '2024-01-01' AND '2024-12-31') OR id IS NULL"
rows={3}
className={`w-full border border-gray-200 rounded px-2 py-1.5 text-xs font-mono bg-white disabled:bg-gray-50 disabled:text-gray-500`}
/>
{!disabled && (
<p className="text-xs text-amber-700 mt-1">Raw SQL is not validated. You are responsible for correctness and security.</p>
)}
</div>
)}
{/* Compiled SQL preview (only meaningful when not in raw mode) */}
{!useRaw && (
<div className="ml-28 mt-2 flex items-start gap-2">
<span className="text-xs text-gray-400 w-12 shrink-0 pt-0.5">SQL</span>
<code className="text-xs font-mono text-gray-700 bg-gray-50 border border-gray-200 rounded px-2 py-1 flex-1 break-all">
{compiled || <span className="text-gray-300 not-italic"> add a condition </span>}
</code>
</div>
)}
</div>
{/* Date offset — baseline only */}
{segType === 'baseline' && (
{/* Date offset */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<div className="flex items-center gap-2">
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<span className="text-xs text-gray-500">yr</span>
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<input disabled={disabled} type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
@ -450,8 +670,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
offsetYr={offsetYr}
offsetMo={offsetMo}
type={segType}
/>
</div>
@ -462,18 +682,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label>
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
<input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
</div>
<button onClick={loadSegment} disabled={submitting || 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 ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
{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'}`)}
</button>
</div>
</div>
</div>
</>}
)}
</div>
</div>
)

View File

@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) {
return c
}
export default function Forecast({ sourceId, versionId }) {
export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
const { dark } = useTheme()
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
@ -49,6 +49,7 @@ export default function Forecast({ sourceId, versionId }) {
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
const [scaleValue, setScaleValue] = useState('')
const [scaleUnits, setScaleUnits] = useState('')
const [scalePrice, setScalePrice] = useState('')
const [scalePct, setScalePct] = useState(false)
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
@ -71,6 +72,7 @@ export default function Forecast({ sourceId, versionId }) {
const tableRef = useRef(null)
const colMetaRef = useRef([])
const expandDepthRef = useRef(null)
const initIdRef = useRef(0)
function onDragStart(e) {
e.preventDefault()
@ -100,6 +102,7 @@ export default function Forecast({ sourceId, versionId }) {
setCloneSet(blank)
setScaleValue('')
setScaleUnits('')
setScalePrice('')
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
else setCurrentTotals(null)
}, [slice])
@ -120,8 +123,22 @@ export default function Forecast({ sourceId, versionId }) {
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
const buckets = new Map()
for (const r of rows) {
const k = r.pf_iter || '?'
const t = buckets.get(k) || { value: 0, units: 0 }
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
buckets.set(k, t)
}
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
setCurrentTotals({ byIter, total, valueCol, unitsCol })
} catch {
setCurrentTotals(null)
}
@ -136,6 +153,7 @@ export default function Forecast({ sourceId, versionId }) {
async function initViewer(vid, sid) {
const viewer = viewerRef.current
if (!viewer) return
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
@ -177,12 +195,37 @@ export default function Forecast({ sourceId, versionId }) {
if (rowCount >= 500000) setLargeDataset(true)
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
const worker = await perspective.worker()
workerRef.current = worker
tableRef.current = rowCount > 0
? await worker.table(buffer, { name: tableName, index: 'pf_id' })
: await worker.table([], { name: tableName, index: 'pf_id' })
if (myId !== initIdRef.current) return
if (!workerRef.current) workerRef.current = await perspective.worker()
const worker = workerRef.current
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
const opts = { name: tableName, index: 'pf_id' }
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
try {
tableRef.current = await makeTable()
} catch (err) {
if (/already exists/i.test(String(err?.message || err))) {
try {
const existing = await worker.open_table(tableName)
if (existing) await existing.delete()
} catch {}
tableRef.current = await makeTable()
} else {
throw err
}
}
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
@ -191,20 +234,38 @@ export default function Forecast({ sourceId, versionId }) {
const saved = localStorage.getItem(LAYOUT_KEY(vid))
if (saved) {
const cfg = cleanLayout(JSON.parse(saved), validCols)
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
const plugin = await viewer.getPlugin()
await plugin.restore({ edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) })
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
const dims = meta.filter(c => c.role === 'dimension').map(c => c.cname)
const dateCol = meta.find(c => c.role === 'date')?.cname
const cfg = { table: tableName, settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }
if (dims.length) cfg.group_by = dims.slice(0, 2)
if (dateCol) cfg.split_by = [dateCol]
await viewer.restore(cfg)
const plugin = await viewer.getPlugin()
await plugin.restore({ edit_mode: 'SELECT_REGION' })
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
let cfg
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
cfg = cleanLayout(sourceDefault, validCols)
cfg.table = tableName
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
} else {
const valueCol = meta.find(c => c.role === 'value')?.cname
cfg = {
table: tableName,
settings: false,
group_by: ['pf_iter'],
columns: valueCol ? [valueCol] : [],
plugin_config: { edit_mode: 'SELECT_REGION' }
}
}
await viewer.restore(cfg)
}
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
viewer._pspUpdate = async () => {
try {
const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg)
} catch {}
}
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
// click slice via event filters (Perspective encodes row position as [col,'==',val] triples)
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
@ -243,9 +304,8 @@ export default function Forecast({ sourceId, versionId }) {
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const plugin = await viewer.getPlugin()
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
const cfg = await viewer.save()
return { ...cfg, expand_depth: expandDepthRef.current }
}
async function persistLayout(vid, cfg) {
@ -268,6 +328,22 @@ export default function Forecast({ sourceId, versionId }) {
flash('Saved')
}
async function saveAsSourceDefault() {
const cfg = await captureConfig()
if (!cfg) return
const { table, expand_depth, ...rest } = cfg
try {
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest)
})
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
if (refreshSources) await refreshSources()
flash('Saved as source default')
} catch (err) { flash(err.message, 'error') }
}
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
@ -283,13 +359,10 @@ export default function Forecast({ sourceId, versionId }) {
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).schema()) : [])
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const cfg = cleanLayout(layout.config, validCols)
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
if (cfg.plugin_config) {
const plugin = await viewer.getPlugin()
await plugin.restore(cfg.plugin_config)
}
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id)
await persistLayout(versionId, cfg)
@ -317,10 +390,16 @@ export default function Forecast({ sourceId, versionId }) {
if (op === 'scale') {
let vi = null, ui = null
if (scaleMode === 'target') {
if (scaleValue !== '' && currentTotals?.value != null)
vi = parseFloat(scaleValue) - currentTotals.value
if (scaleUnits !== '' && currentTotals?.units != null)
ui = parseFloat(scaleUnits) - currentTotals.units
const curValue = currentTotals?.total?.value
const curUnits = currentTotals?.total?.units
if (scalePrice !== '' && curUnits != null && curValue != null) {
// hold units constant; new value = price × current units
vi = (parseFloat(scalePrice) * curUnits) - curValue
}
if (scaleValue !== '' && curValue != null)
vi = parseFloat(scaleValue) - curValue
if (scaleUnits !== '' && curUnits != null)
ui = parseFloat(scaleUnits) - curUnits
} else {
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
@ -345,7 +424,7 @@ export default function Forecast({ sourceId, versionId }) {
if (!res.ok) { flash(data.error, 'error'); return }
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
@ -437,6 +516,11 @@ export default function Forecast({ sourceId, versionId }) {
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
Save as
</button>
<button onClick={saveAsSourceDefault} disabled={!sourceId}
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
title="Use this layout as the default for new versions of this source">
Set source default
</button>
{activeLayoutId !== null && (
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
)}
@ -610,6 +694,40 @@ export default function Forecast({ sourceId, versionId }) {
)}
</div>
{hasSlice && currentTotals?.byIter?.length > 0 && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400" style={{fontSize:'10px'}}>
<th className="text-left font-normal pb-1"></th>
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
</tr>
</thead>
<tbody>
{currentTotals.byIter.map(r => (
<tr key={r.iter}>
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
</tr>
))}
{currentTotals.byIter.length > 1 && (
<tr className="border-t border-gray-100">
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
</tr>
)}
</tbody>
</table>
</div>
)}
{hasSlice && (
<>
<div className="flex border-b border-gray-100">
@ -626,39 +744,38 @@ export default function Forecast({ sourceId, versionId }) {
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden">
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
{/* Value row */}
{currentTotals?.valueCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.valueCol}</span>
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<Row label={currentTotals.valueCol}>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</div>
</Row>
)}
{/* Units row */}
{currentTotals?.unitsCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.unitsCol}</span>
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<Row label={currentTotals.unitsCol}>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</div>
</Row>
)}
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
<Row label="price">
<input type="number" step="any" value={scalePrice}
onChange={e => setScalePrice(e.target.value)}
placeholder="target price (holds units)"
className={inp} />
</Row>
)}
{scaleMode === 'delta' && (
@ -712,6 +829,11 @@ function fmtBytes(n) {
return `${(n / 1048576).toFixed(1)} MB`
}
function fmtNum(n, decimals = 2) {
if (n == null || !isFinite(n)) return '—'
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
}
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}