/* ============================================================
STATE
============================================================ */
const state = {
view: 'sources',
source: null, // selected pf.source row
version: null, // selected pf.version row
selectedVersionId: null, // in versions grid
colMeta: [], // col_meta for selected source
slice: {}, // current pivot selection
loadDataOp: null, // 'baseline' | 'reference'
baselineFilterCols: [],
previewSchema: null,
previewTname: null,
grids: {
tables: null,
sources: null,
colMeta: null,
versions: null,
pivot: null,
log: null
}
};
/* ============================================================
API
============================================================ */
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
/* ============================================================
STATUS BAR
============================================================ */
let statusTimer = null;
function showStatus(msg, type = 'info') {
const bar = document.getElementById('status-bar');
bar.textContent = msg;
bar.className = `status-${type}`;
bar.classList.remove('hidden');
clearTimeout(statusTimer);
if (type !== 'error') statusTimer = setTimeout(() => bar.classList.add('hidden'), 4000);
}
/* ============================================================
NAVIGATION
============================================================ */
function switchView(name) {
if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) {
showStatus('Select a version first', 'error');
return;
}
document.querySelectorAll('.view').forEach(el => {
el.classList.add('hidden');
el.classList.remove('active');
});
const target = document.getElementById(`view-${name}`);
target.classList.remove('hidden');
target.classList.add('active');
document.querySelectorAll('.nav-links li').forEach(li =>
li.classList.toggle('active', li.dataset.view === name)
);
state.view = name;
if (name === 'versions') renderVersions();
if (name === 'forecast') loadForecastData();
if (name === 'log') loadLogData();
if (name === 'baseline') openBaselineWorkbench();
}
function setSource(source) {
state.source = source;
const el = document.getElementById('ctx-source');
if (source) {
el.classList.remove('hidden');
document.getElementById('ctx-source-name').textContent = `${source.schema}.${source.tname}`;
} else {
el.classList.add('hidden');
}
}
function setVersion(version) {
state.version = version;
const el = document.getElementById('ctx-version');
if (version) {
el.classList.remove('hidden');
document.getElementById('ctx-version-name').textContent = version.name;
const badge = document.getElementById('ctx-version-status');
badge.textContent = version.status;
badge.className = `status-badge ${version.status}`;
} else {
el.classList.add('hidden');
}
}
function getPfUser() {
return document.getElementById('input-pf-user').value.trim() || 'unknown';
}
/* ============================================================
SOURCES VIEW — table browser
============================================================ */
async function initSourcesView() {
const tables = await api('GET', '/tables');
renderTablesGrid(tables);
const sources = await api('GET', '/sources');
renderSourcesGrid(sources);
}
function renderTablesGrid(tables) {
const el = document.getElementById('tables-grid');
if (state.grids.tables) { state.grids.tables.setGridOption('rowData', tables); return; }
state.grids.tables = agGrid.createGrid(el, {
columnDefs: [
{ field: 'schema', headerName: 'Schema', width: 90 },
{ field: 'tname', headerName: 'Table', flex: 1 },
{ field: 'row_estimate', headerName: 'Rows', width: 80,
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
],
rowData: tables,
rowSelection: 'single',
onRowClicked: onTableRowClicked,
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32, rowHeight: 28
});
}
function onTableRowClicked(e) {
const { schema, tname } = e.data;
state.previewSchema = schema;
state.previewTname = tname;
document.getElementById('btn-register').classList.remove('hidden');
}
async function showTablePreview(schema, tname) {
try {
const data = await api('GET', `/tables/${schema}/${tname}/preview`);
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
title.textContent = `${schema}.${tname}`;
// columns table
let colHtml = `
Columns
| Column | Type | Nullable |
`;
data.columns.forEach(c => {
colHtml += `| ${c.column_name} | ${c.data_type} | ${c.is_nullable} |
`;
});
colHtml += '
';
// sample rows
let rowHtml = '';
if (data.rows.length > 0) {
const cols = Object.keys(data.rows[0]);
rowHtml = `Sample rows
`;
cols.forEach(c => rowHtml += `| ${c} | `);
rowHtml += '
';
data.rows.forEach(row => {
rowHtml += '';
cols.forEach(c => rowHtml += `| ${row[c] ?? ''} | `);
rowHtml += '
';
});
rowHtml += '
';
}
body.innerHTML = colHtml + rowHtml;
state.previewSchema = schema;
state.previewTname = tname;
document.getElementById('modal-overlay').classList.remove('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
async function registerTable(schema, tname) {
try {
await api('POST', '/sources', { schema, tname, created_by: getPfUser() });
showStatus(`Registered ${schema}.${tname}`, 'success');
const sources = await api('GET', '/sources');
renderSourcesGrid(sources);
document.getElementById('modal-overlay').classList.add('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
SOURCES VIEW — registered sources + col_meta
============================================================ */
function renderSourcesGrid(sources) {
const el = document.getElementById('sources-list-grid');
if (state.grids.sources) { state.grids.sources.setGridOption('rowData', sources); return; }
state.grids.sources = agGrid.createGrid(el, {
columnDefs: [
{ field: 'schema', headerName: 'Schema', width: 90 },
{ field: 'tname', headerName: 'Table', flex: 1 },
{ field: 'label', headerName: 'Label', flex: 1 },
{ field: 'status', headerName: 'Status', width: 70 }
],
rowData: sources,
rowSelection: 'single',
onRowClicked: e => selectSource(e.data),
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32, rowHeight: 28
});
}
async function selectSource(source) {
setSource(source);
try {
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
renderColMetaGrid(state.colMeta);
document.getElementById('sources-list-grid').classList.add('hidden');
document.getElementById('col-meta-grid').classList.remove('hidden');
document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`;
document.getElementById('btn-back-sources').classList.remove('hidden');
document.getElementById('btn-save-cols').classList.remove('hidden');
document.getElementById('btn-generate-sql').classList.remove('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
function backToSources() {
document.getElementById('sources-list-grid').classList.remove('hidden');
document.getElementById('col-meta-grid').classList.add('hidden');
document.getElementById('right-panel-title').textContent = 'Registered Sources';
document.getElementById('btn-back-sources').classList.add('hidden');
document.getElementById('btn-save-cols').classList.add('hidden');
document.getElementById('btn-generate-sql').classList.add('hidden');
}
function renderColMetaGrid(colMeta) {
const el = document.getElementById('col-meta-grid');
if (state.grids.colMeta) { state.grids.colMeta.setGridOption('rowData', colMeta); return; }
state.grids.colMeta = agGrid.createGrid(el, {
columnDefs: [
{ field: 'opos', headerName: '#', width: 45, sortable: true },
{ field: 'cname', headerName: 'Column', flex: 1 },
{
field: 'role', headerName: 'Role', width: 110, editable: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] },
cellStyle: p => {
const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', filter: '#fef5e4', ignore: '#f9f9f9' };
return { background: colors[p.value] || '' };
}
},
{
field: 'is_key', headerName: 'Key', width: 55, editable: true,
cellRenderer: p => p.value ? '✓' : '',
cellEditor: 'agCheckboxCellEditor'
},
{ field: 'label', headerName: 'Label', flex: 1, editable: true,
cellEditorParams: { useFormatter: true } }
],
rowData: colMeta,
defaultColDef: { resizable: true },
headerHeight: 32, rowHeight: 28,
singleClickEdit: true,
stopEditingWhenCellsLoseFocus: true
});
}
async function saveColMeta() {
if (!state.source) return;
try {
const rows = [];
state.grids.colMeta.forEachNode(n => rows.push(n.data));
state.colMeta = await api('PUT', `/sources/${state.source.id}/cols`, rows);
state.grids.colMeta.setGridOption('rowData', state.colMeta);
showStatus('Columns saved', 'success');
} catch (err) {
showStatus(err.message, 'error');
}
}
async function generateSQL() {
if (!state.source) return;
try {
const result = await api('POST', `/sources/${state.source.id}/generate-sql`);
showStatus(`SQL generated: ${result.operations.join(', ')}`, 'success');
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
VERSIONS VIEW
============================================================ */
async function renderVersions() {
if (!state.source) {
document.getElementById('versions-source-label').textContent = 'No source selected — go to Sources first';
return;
}
document.getElementById('versions-source-label').textContent = `${state.source.schema}.${state.source.tname}`;
try {
const versions = await api('GET', `/sources/${state.source.id}/versions`);
renderVersionsGrid(versions);
} catch (err) {
showStatus(err.message, 'error');
}
}
function renderVersionsGrid(versions) {
const el = document.getElementById('versions-grid');
const colDefs = [
{ field: 'id', headerName: 'ID', width: 55 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'description', headerName: 'Desc', flex: 1 },
{ field: 'status', headerName: 'Status', width: 75,
cellStyle: p => ({ color: p.value === 'open' ? '#27ae60' : '#e74c3c', fontWeight: 600 }) },
{ field: 'created_by', headerName: 'Created by', width: 100 },
{ field: 'created_at', headerName: 'Created', width: 140,
valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' }
];
if (state.grids.versions) {
state.grids.versions.setGridOption('rowData', versions);
state.grids.versions.setGridOption('columnDefs', colDefs);
return;
}
state.grids.versions = agGrid.createGrid(el, {
columnDefs: colDefs,
rowData: versions,
rowSelection: 'single',
onRowClicked: onVersionRowClicked,
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32, rowHeight: 28
});
}
function onVersionRowClicked(e) {
const v = e.data;
state.selectedVersionId = v.id;
const panel = document.getElementById('version-actions');
panel.classList.remove('hidden');
document.getElementById('version-actions-label').textContent = v.name;
document.getElementById('vbtn-toggle').textContent = v.status === 'open' ? 'Close Version' : 'Reopen Version';
document.getElementById('load-data-form').classList.add('hidden');
}
function showNewVersionForm() {
document.getElementById('new-version-form').classList.remove('hidden');
document.getElementById('ver-name').focus();
}
async function createVersion() {
const name = document.getElementById('ver-name').value.trim();
if (!name) { showStatus('Version name is required', 'error'); return; }
try {
await api('POST', `/sources/${state.source.id}/versions`, {
name,
description: document.getElementById('ver-desc').value.trim() || undefined,
created_by: getPfUser()
});
document.getElementById('new-version-form').classList.add('hidden');
document.getElementById('ver-name').value = '';
document.getElementById('ver-desc').value = '';
showStatus(`Version "${name}" created`, 'success');
await renderVersions();
} catch (err) {
showStatus(err.message, 'error');
}
}
function showLoadForm(op) {
state.loadDataOp = op;
document.getElementById('load-data-title').textContent =
op === 'baseline' ? 'Load Baseline' : 'Load Reference';
document.getElementById('load-date-from').value = '';
document.getElementById('load-date-to').value = '';
document.getElementById('load-offset-years').value = '0';
document.getElementById('load-offset-months').value = '0';
document.getElementById('load-note').value = '';
document.getElementById('load-date-preview').classList.add('hidden');
const showOffset = op === 'baseline';
document.getElementById('load-offset-fields').classList.toggle('hidden', !showOffset);
document.getElementById('load-data-modal').classList.remove('hidden');
document.getElementById('load-date-from').focus();
}
function hideLoadModal() {
document.getElementById('load-data-modal').classList.add('hidden');
}
function buildMonthList(fromVal, toVal) {
const from = new Date(fromVal + 'T00:00:00');
const to = new Date(toVal + 'T00:00:00');
if (isNaN(from) || isNaN(to) || from > to) return null;
const months = [];
const cur = new Date(from.getFullYear(), from.getMonth(), 1);
const end = new Date(to.getFullYear(), to.getMonth(), 1);
while (cur <= end) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
return months;
}
function renderChips(months, fmt) {
if (months.length <= 36) {
return months.map(m => `${fmt.format(m)}`).join('');
}
return `${months.length} months — ${fmt.format(months[0])} → ${fmt.format(months[months.length - 1])}`;
}
function updateDatePreview() {
const fromVal = document.getElementById('load-date-from').value;
const toVal = document.getElementById('load-date-to').value;
const preview = document.getElementById('load-date-preview');
const simple = document.getElementById('load-preview-simple');
const offset = document.getElementById('load-preview-offset');
if (!fromVal || !toVal) { preview.classList.add('hidden'); return; }
const months = buildMonthList(fromVal, toVal);
if (!months) { preview.classList.add('hidden'); return; }
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
if (state.loadDataOp === 'baseline') {
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
const mths = parseInt(document.getElementById('load-offset-months').value) || 0;
const projected = months.map(d => {
const p = new Date(d);
p.setFullYear(p.getFullYear() + years);
p.setMonth(p.getMonth() + mths);
return p;
});
document.getElementById('load-chips-source').innerHTML = renderChips(months, fmt);
document.getElementById('load-chips-projected').innerHTML = renderChips(projected, fmt);
simple.classList.add('hidden');
offset.classList.remove('hidden');
} else {
simple.querySelector('.load-preview-label').textContent =
`${months.length} month${months.length !== 1 ? 's' : ''} covered`;
document.getElementById('load-date-chips').innerHTML = renderChips(months, fmt);
offset.classList.add('hidden');
simple.classList.remove('hidden');
}
preview.classList.remove('hidden');
}
async function submitLoadData() {
const date_from = document.getElementById('load-date-from').value;
const date_to = document.getElementById('load-date-to').value;
if (!date_from || !date_to) { showStatus('Both dates are required', 'error'); return; }
if (!state.selectedVersionId) { showStatus('No version selected', 'error'); return; }
const body = {
date_from, date_to,
pf_user: getPfUser(),
note: document.getElementById('load-note').value.trim() || undefined
};
if (state.loadDataOp === 'baseline') {
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
const months = parseInt(document.getElementById('load-offset-months').value) || 0;
const parts = [];
if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
body.date_offset = parts.length ? parts.join(' ') : '0 days';
}
try {
showStatus(`Loading ${state.loadDataOp}...`, 'info');
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
hideLoadModal();
showStatus(`${state.loadDataOp} loaded — ${result.rows_affected} rows`, 'success');
} catch (err) {
showStatus(err.message, 'error');
}
}
async function toggleVersionStatus() {
if (!state.selectedVersionId) return;
// find current status from grid
let currentStatus = null;
state.grids.versions.forEachNode(n => {
if (n.data.id === state.selectedVersionId) currentStatus = n.data.status;
});
try {
const route = currentStatus === 'open'
? `/versions/${state.selectedVersionId}/close`
: `/versions/${state.selectedVersionId}/reopen`;
const result = await api('POST', route, { pf_user: getPfUser() });
showStatus(`Version ${result.status}`, 'success');
await renderVersions();
document.getElementById('version-actions').classList.add('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
async function deleteVersion() {
if (!state.selectedVersionId) return;
if (!confirm('Delete this version and its forecast table? This cannot be undone.')) return;
try {
await api('DELETE', `/versions/${state.selectedVersionId}`);
showStatus('Version deleted', 'success');
if (state.version?.id === state.selectedVersionId) setVersion(null);
state.selectedVersionId = null;
document.getElementById('version-actions').classList.add('hidden');
await renderVersions();
} catch (err) {
showStatus(err.message, 'error');
}
}
function openForecast() {
if (!state.selectedVersionId) return;
// find the version object from grid
let v = null;
state.grids.versions.forEachNode(n => {
if (n.data.id === state.selectedVersionId) v = n.data;
});
if (!v) return;
setVersion(v);
switchView('forecast');
}
function openVersionBaseline() {
if (!state.selectedVersionId) return;
let v = null;
state.grids.versions.forEachNode(n => {
if (n.data.id === state.selectedVersionId) v = n.data;
});
if (!v) return;
setVersion(v);
switchView('baseline');
}
/* ============================================================
BASELINE WORKBENCH
============================================================ */
async function openBaselineWorkbench() {
if (!state.version) return;
document.getElementById('baseline-label').textContent =
`${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`;
// ensure colMeta loaded
if (!state.colMeta.length && state.source) {
state.colMeta = await api('GET', `/sources/${state.source.id}/cols`);
}
state.baselineFilterCols = state.colMeta.filter(c => c.role === 'date' || c.role === 'filter');
// reset form
document.getElementById('seg-description').value = '';
document.getElementById('seg-offset-type').value = '';
document.getElementById('seg-offset-value').value = '0';
document.getElementById('seg-filter-rows').innerHTML = '';
document.getElementById('seg-timeline').classList.add('hidden');
addFilterRow(); // start with one empty filter row
await loadBaselineSegments();
}
async function loadBaselineSegments() {
if (!state.version) return;
try {
const logs = await api('GET', `/versions/${state.version.id}/log`);
const segments = logs.filter(l => l.operation === 'baseline');
renderBaselineSegments(segments);
} catch (err) {
showStatus(err.message, 'error');
}
}
function renderBaselineSegments(segments) {
const el = document.getElementById('baseline-segments-list');
if (segments.length === 0) {
el.innerHTML = 'No segments loaded yet.
';
return;
}
el.innerHTML = segments.map(s => {
const params = s.params || {};
const filters = (params.filters || [])
.map(f => {
if (f.op === 'IS NULL' || f.op === 'IS NOT NULL') return `${f.col} ${f.op}`;
if (f.op === 'BETWEEN') return `${f.col} BETWEEN ${(f.values||[]).join(' AND ')}`;
if (f.op === 'IN' || f.op === 'NOT IN') return `${f.col} ${f.op} (${(f.values||[]).join(', ')})`;
return `${f.col} ${f.op} ${(f.values||[])[0] || ''}`;
}).join('\n');
const offset = params.date_offset && params.date_offset !== '0 days' ? ` · offset: ${params.date_offset}` : '';
const stamp = s.stamp ? new Date(s.stamp).toLocaleString() : '';
return `
`;
}).join('');
}
function addFilterRow() {
const container = document.getElementById('seg-filter-rows');
const idx = container.children.length;
const colOptions = state.baselineFilterCols
.map(c => ``)
.join('');
const div = document.createElement('div');
div.className = 'filter-row';
div.dataset.index = idx;
div.innerHTML = `
`;
div.querySelector('.filter-op-select').addEventListener('change', () => {
updateFilterValueInputs(div);
updateTimelinePreview();
});
div.querySelector('.filter-col-select').addEventListener('change', () => {
updateTimelinePreview();
});
div.querySelector('.filter-remove-btn').addEventListener('click', () => {
div.remove();
updateTimelinePreview();
});
updateFilterValueInputs(div);
container.appendChild(div);
}
function updateFilterValueInputs(row) {
const op = row.querySelector('.filter-op-select').value;
const container = row.querySelector('.filter-value-container');
if (op === 'IS NULL' || op === 'IS NOT NULL') {
container.innerHTML = '';
return;
}
if (op === 'BETWEEN') {
container.innerHTML = `
and
`;
container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview));
return;
}
if (op === 'IN' || op === 'NOT IN') {
container.innerHTML = ``;
return;
}
// = or !=
container.innerHTML = ``;
}
function getFilterRows() {
const rows = [];
document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => {
const col = row.querySelector('.filter-col-select').value;
const op = row.querySelector('.filter-op-select').value;
if (!col) return;
const filter = { col, op, values: [] };
if (op === 'BETWEEN') {
const from = row.querySelector('.filter-val-from')?.value.trim();
const to = row.querySelector('.filter-val-to')?.value.trim();
if (from) filter.values.push(from);
if (to) filter.values.push(to);
} else if (op === 'IN' || op === 'NOT IN') {
const raw = row.querySelector('.filter-val-list')?.value.trim();
if (raw) filter.values = raw.split(',').map(s => s.trim()).filter(Boolean);
} else if (op !== 'IS NULL' && op !== 'IS NOT NULL') {
const val = row.querySelector('.filter-val-single')?.value.trim();
if (val) filter.values.push(val);
}
rows.push(filter);
});
return rows;
}
function updateTimelinePreview() {
const el = document.getElementById('seg-timeline');
// find first BETWEEN filter on a date-role column
let dateFrom = null, dateTo = null;
document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => {
if (dateFrom) return;
const col = row.querySelector('.filter-col-select').value;
const op = row.querySelector('.filter-op-select').value;
if (op !== 'BETWEEN') return;
const meta = state.baselineFilterCols.find(c => c.cname === col);
if (!meta || meta.role !== 'date') return;
const from = row.querySelector('.filter-val-from')?.value.trim();
const to = row.querySelector('.filter-val-to')?.value.trim();
if (from && to) { dateFrom = from; dateTo = to; }
});
if (!dateFrom || !dateTo) { el.classList.add('hidden'); return; }
const from = new Date(dateFrom + 'T00:00:00');
const to = new Date(dateTo + 'T00:00:00');
if (isNaN(from) || isNaN(to) || from > to) { el.classList.add('hidden'); return; }
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) + 1;
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
const offsetType = document.getElementById('seg-offset-type').value;
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
function applyOffset(d) {
const r = new Date(d);
if (!offsetType || !offsetValue) return r;
switch (offsetType) {
case 'year': r.setFullYear(r.getFullYear() + offsetValue); break;
case 'month': r.setMonth(r.getMonth() + offsetValue); break;
case 'week': r.setDate(r.getDate() + offsetValue * 7); break;
case 'day': r.setDate(r.getDate() + offsetValue); break;
}
return r;
}
const projFrom = applyOffset(from);
const projTo = applyOffset(to);
let html = `
Source
${fmt.format(from)}
${months} month${months !== 1 ? 's' : ''}
${fmt.format(to)}
`;
if (offsetType && offsetValue) {
html += `
+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} →
Projected
${fmt.format(projFrom)}
${months} month${months !== 1 ? 's' : ''}
${fmt.format(projTo)}
`;
}
el.innerHTML = html;
el.classList.remove('hidden');
}
async function submitBaselineSegment() {
if (!state.version) return;
const filters = getFilterRows();
if (filters.length === 0) { showStatus('Add at least one filter', 'error'); return; }
const offsetType = document.getElementById('seg-offset-type').value;
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
const date_offset = (offsetType && offsetValue)
? `${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''}`
: '0 days';
const body = {
filters,
date_offset,
pf_user: getPfUser(),
note: document.getElementById('seg-description').value.trim() || undefined
};
try {
showStatus('Loading segment...', 'info');
const result = await api('POST', `/versions/${state.version.id}/baseline`, body);
showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success');
document.getElementById('seg-description').value = '';
document.getElementById('seg-filter-rows').innerHTML = '';
document.getElementById('seg-timeline').classList.add('hidden');
addFilterRow();
await loadBaselineSegments();
} catch (err) {
showStatus(err.message, 'error');
}
}
async function clearBaseline() {
if (!state.version) return;
if (!confirm('Delete all baseline rows and segment history for this version? This cannot be undone.')) return;
try {
const result = await api('DELETE', `/versions/${state.version.id}/baseline`);
showStatus(`Baseline cleared — ${result.rows_deleted} rows removed`, 'success');
await loadBaselineSegments();
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
FORECAST VIEW — data loading
============================================================ */
function parseNumericRows(rows) {
const numericCols = state.colMeta
.filter(c => c.role === 'value' || c.role === 'units')
.map(c => c.cname);
return rows.map(row => {
const r = { ...row };
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
return r;
});
}
async function loadForecastData() {
if (!state.version) return;
document.getElementById('forecast-label').textContent =
`${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`;
try {
// ensure col_meta is loaded (may not be if user navigated directly)
if (!state.colMeta.length && state.source) {
state.colMeta = await api('GET', `/sources/${state.source.id}/cols`);
}
showStatus('Loading forecast data...', 'info');
const rawData = await api('GET', `/versions/${state.version.id}/data`);
const data = parseNumericRows(rawData);
initPivotGrid(data);
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
FORECAST VIEW — pivot grid
============================================================ */
function buildPivotColDefs() {
const defs = [];
state.colMeta.forEach((c) => {
if (c.role === 'ignore') return;
const needsGetter = /\W/.test(c.cname);
const def = {
field: c.cname,
headerName: c.label || c.cname,
resizable: true,
sortable: true,
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {})
};
if (c.role === 'dimension' || c.role === 'date') {
def.enableRowGroup = true;
def.enablePivot = true;
}
if (c.role === 'value' || c.role === 'units') {
def.enableValue = true;
def.aggFunc = 'sum';
def.type = 'numericColumn';
def.valueFormatter = p => p.value != null
? Number(p.value).toLocaleString(undefined, { maximumFractionDigits: 2 })
: '';
}
defs.push(def);
if (c.role === 'date') {
defs.push({
colId: c.cname + '__month',
headerName: (c.label || c.cname) + ' (Month)',
enableRowGroup: true,
enablePivot: true,
valueGetter: p => {
const v = p.data ? p.data[c.cname] : undefined;
if (!v) return undefined;
const d = new Date(v);
return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
});
}
});
// always include iter for grouping context
defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true,
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
return defs;
}
function initPivotGrid(data) {
const el = document.getElementById('pivot-grid');
if (state.grids.pivot) {
state.grids.pivot.setGridOption('rowData', data);
return;
}
state.grids.pivot = agGrid.createGrid(el, {
columnDefs: buildPivotColDefs(),
rowData: data,
rowSelection: 'single',
groupDisplayType: 'singleColumn',
rowGroupPanelShow: 'always',
groupDefaultExpanded: 1,
suppressAggFuncInHeader: true,
animateRows: true,
sideBar: {
toolPanels: [{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
toolPanelParams: {}
}],
defaultToolPanel: 'columns'
},
defaultColDef: { resizable: true, sortable: true },
autoGroupColumnDef: {
headerName: 'Group',
minWidth: 200,
cellRendererParams: { suppressCount: false }
},
headerHeight: 32,
rowHeight: 28,
onRowClicked: onPivotRowClicked
});
}
/* ============================================================
FORECAST VIEW — slice selection
============================================================ */
function onPivotRowClicked(event) {
const node = event.node;
state.slice = extractSliceFromNode(node);
renderSliceDisplay();
// populate recode and clone fields whenever slice changes
renderDimFields('recode');
renderDimFields('clone');
}
function extractSliceFromNode(node) {
const slice = {};
let current = node;
while (current) {
if (current.field && current.key != null && current.key !== '') {
slice[current.field] = current.key;
}
current = current.parent;
}
return slice;
}
function renderSliceDisplay() {
const display = document.getElementById('slice-display');
const hasSlice = Object.keys(state.slice).length > 0;
if (!hasSlice) {
display.innerHTML = 'Click a row to select a slice';
document.getElementById('btn-clear-slice').classList.add('hidden');
document.getElementById('op-forms-area').classList.add('hidden');
return;
}
display.innerHTML = Object.entries(state.slice)
.map(([k, v]) => `${k} = ${v}`)
.join('');
document.getElementById('btn-clear-slice').classList.remove('hidden');
document.getElementById('op-forms-area').classList.remove('hidden');
}
function clearSlice() {
state.slice = {};
renderSliceDisplay();
}
/* ============================================================
FORECAST VIEW — operation tabs
============================================================ */
function switchOpTab(opName) {
document.querySelectorAll('.op-tab').forEach(t =>
t.classList.toggle('active', t.dataset.op === opName)
);
document.querySelectorAll('.op-form').forEach(f => f.classList.add('hidden'));
document.getElementById(`op-${opName}`).classList.remove('hidden');
}
// render input fields for each dimension column (recode / clone)
// is_key columns get a populated