/* ============================================================
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'
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') && !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();
}
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', 'ignore'] },
cellStyle: p => {
const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', 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-note').value = '';
document.getElementById('load-date-preview').classList.add('hidden');
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 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 chips = document.getElementById('load-date-chips');
const label = preview.querySelector('.load-preview-label');
if (!fromVal || !toVal) { preview.classList.add('hidden'); return; }
const from = new Date(fromVal + 'T00:00:00');
const to = new Date(toVal + 'T00:00:00');
if (isNaN(from) || isNaN(to) || from > to) { preview.classList.add('hidden'); return; }
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); }
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
label.textContent = `${months.length} month${months.length !== 1 ? 's' : ''} covered`;
if (months.length <= 36) {
chips.innerHTML = months.map(m => `${fmt.format(m)}`).join('');
} else {
chips.innerHTML = `${months.length} months — ${fmt.format(months[0])} → ${fmt.format(months[months.length - 1])}`;
}
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
};
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');
}
/* ============================================================
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