Compare commits

..

No commits in common. "39335bca758a24b9107a5e78b7c90477af89a234" and "e279a510d83446ca6f94e87473e4d445308befe0" have entirely different histories.

7 changed files with 276 additions and 863 deletions

View File

@ -69,9 +69,6 @@ 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 (
@ -81,7 +78,7 @@ ilog AS (
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${referenceSelect}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE {{filter_clause}}
RETURNING *

View File

@ -123,23 +123,18 @@ 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, filters, raw_where } = req.body;
const { where_clause, date_offset, pf_user, note } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const filterClause = (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(paramsJson),
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
@ -152,82 +147,6 @@ 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);
@ -264,25 +183,18 @@ 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, 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 { where_clause, pf_user, note } = req.body;
const filterClause = (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(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
params: esc(JSON.stringify({ where_clause: filterClause })),
filter_clause: filterClause
});
const result = await runSQL(sql);
@ -342,8 +254,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -378,8 +289,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -416,8 +326,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -429,36 +338,19 @@ module.exports = function(pool) {
const versionId = parseInt(req.params.id);
try {
const verResult = await pool.query(
`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`,
`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) return res.status(404).json({ error: 'Version not found' });
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 table = fcTable(verResult.rows[0].tname, versionId);
const result = await pool.query(`
SELECT l.*, ${aggCols},
$2::text AS value_col,
$3::text AS units_col
SELECT l.*, count(f.pf_id)::int AS row_count
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, valueCol || null, unitsCol || null]);
`, [versionId]);
res.json(result.rows);
} catch (err) {
console.error(err);

View File

@ -222,24 +222,6 @@ 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

@ -4,20 +4,16 @@
CREATE SCHEMA IF NOT EXISTS pf;
CREATE TABLE IF NOT EXISTS pf.source (
id serial PRIMARY KEY,
schema text NOT NULL,
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,
id serial PRIMARY KEY,
schema text NOT NULL,
tname text NOT NULL,
label text,
status text NOT NULL DEFAULT 'active', -- active | archived
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
View File

@ -1,113 +0,0 @@
- [ ] 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,48 +3,44 @@ import Timeline from '../components/Timeline.jsx'
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
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 (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("','")}')`
}
if (!c.values[0]) return null
return `${col} ${c.op} '${c.values[0]}'`
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
return `${col} BETWEEN '${a}' AND '${b}'`
}
if (op === 'IN' || op === 'NOT IN') {
const vals = f.values.join("','")
return `${col} ${op} ('${vals}')`
}
return `${col} ${op} '${f.values[0]}'`
})
return parts.join(' AND ')
}
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 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 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] }
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] }
}
}
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
}
@ -55,18 +51,9 @@ function parseOffset(offsetStr) {
return { yr, mo }
}
function emptyCondition(cols) {
function emptyFilter(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([])
@ -78,19 +65,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [newVerDesc, setNewVerDesc] = useState('')
const [creatingVer, setCreatingVer] = useState(false)
// segment form
// add segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
const [useRaw, setUseRaw] = useState(false)
const [rawSql, setRawSql] = useState('')
const [filters, setFilters] = 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)
@ -100,7 +82,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 ? [emptyGroup(fc)] : [])
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
})
}, [sourceId])
@ -112,7 +94,6 @@ 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)))
})
}
@ -140,37 +121,59 @@ 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 = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
const clause = buildFilterClause(filters)
if (!clause) { flash('Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
}
const body = isRef
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
setSubmitting(true)
try {
const url = editingLogId
? `/api/versions/${versionId}/baseline/${editingLogId}`
: `/api/versions/${versionId}/${endpoint}`
const method = editingLogId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(editingLogId
? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}`
: `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
cancelEdit()
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
} catch (err) {
flash(err.message, 'error')
} finally {
@ -178,52 +181,6 @@ 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()
@ -273,12 +230,14 @@ 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}
@ -301,6 +260,7 @@ 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">
@ -337,8 +297,6 @@ 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>
@ -346,31 +304,23 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead>
<tbody>
{log.length === 0 && (
<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>
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{log.map((entry, i) => {
const isOpen = expandedId === entry.id
const view = segmentValuesFor(entry, filterCols)
const params = entry.params || {}
const dr = parseDateRangeFromClause(params.where_clause)
const off = parseOffset(params.date_offset)
return (
<>
<tr
key={entry.id}
onClick={() => setExpandedId(isOpen ? null : entry.id)}
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' : ''}`}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${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'}`}>
@ -378,26 +328,29 @@ 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-2 py-2">
<div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} />
<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>
)}
</div>
</td>
</tr>
@ -409,42 +362,115 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</table>
</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'}`}>
<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'}
{/* 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
</div>
<div className="p-4 flex flex-col gap-4">
{/* Type toggle */}
<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'}`}
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description */}
<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>
</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'
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>)}
</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">
{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" />
<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" />
</>}
{(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" />
)}
{(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" />
)}
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
)
})}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
</div>
{/* Date offset — baseline only */}
{segType === 'baseline' && (
<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" />
<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" />
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
<div className="ml-28">
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
type={segType}
/>
</div>
</div>
)}
{/* Note + submit */}
<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" />
</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'}`}
</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>
</>}
@ -452,248 +478,3 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</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 */}
<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}
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>
</div>
{/* 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>
{!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>
{!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={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 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>
{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 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`} />
</>}
{(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`} />
)}
{(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>
)}
</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 */}
<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 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 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 && (
<div className="ml-28">
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={offsetYr}
offsetMo={offsetMo}
type={segType}
/>
</div>
</div>
)}
{/* Note + submit */}
<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 disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
</div>
{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>
)
}

View File

@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) {
return c
}
export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
export default function Forecast({ sourceId, versionId }) {
const { dark } = useTheme()
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
@ -49,7 +49,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
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({})
@ -72,7 +71,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const tableRef = useRef(null)
const colMetaRef = useRef([])
const expandDepthRef = useRef(null)
const initIdRef = useRef(0)
function onDragStart(e) {
e.preventDefault()
@ -102,7 +100,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
setCloneSet(blank)
setScaleValue('')
setScaleUnits('')
setScalePrice('')
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
else setCurrentTotals(null)
}, [slice])
@ -123,22 +120,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
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 })
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
} catch {
setCurrentTotals(null)
}
@ -153,7 +136,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function initViewer(vid, sid) {
const viewer = viewerRef.current
if (!viewer) return
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
@ -195,37 +177,12 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
if (rowCount >= 500000) setLargeDataset(true)
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
}
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' })
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
@ -234,39 +191,21 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
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 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' }
}
}
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' })
}
// 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)
viewer._pspClick = async (e) => {
@ -304,8 +243,9 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const cfg = await viewer.save()
return { ...cfg, expand_depth: expandDepthRef.current }
const plugin = await viewer.getPlugin()
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
}
async function persistLayout(vid, cfg) {
@ -328,22 +268,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
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
@ -359,10 +283,13 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).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)
@ -390,16 +317,10 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
if (op === 'scale') {
let vi = null, ui = null
if (scaleMode === 'target') {
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
if (scaleValue !== '' && currentTotals?.value != null)
vi = parseFloat(scaleValue) - currentTotals.value
if (scaleUnits !== '' && currentTotals?.units != null)
ui = parseFloat(scaleUnits) - currentTotals.units
} else {
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
@ -424,7 +345,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
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(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
@ -516,11 +437,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
<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>
)}
@ -694,40 +610,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
)}
</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">
@ -744,38 +626,39 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{/* 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(''); setScalePrice('') }}
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
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 && (
<Row label={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>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</Row>
</div>
)}
{/* Units row */}
{currentTotals?.unitsCol && (
<Row label={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>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</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>
</div>
)}
{scaleMode === 'delta' && (
@ -829,11 +712,6 @@ 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' })
}