pf_app/public/app.js
Paul Trowbridge d49aac70e4 Replace offset year/month spinners with type picklist + value input
Offset Type: [Year|Month|Week|Day]  Offset Value: [n]
Cleaner UX, handles all interval types uniformly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:05:25 -04:00

1305 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 = `<div class="preview-section"><h4>Columns</h4><table class="preview-table">
<tr><th>Column</th><th>Type</th><th>Nullable</th></tr>`;
data.columns.forEach(c => {
colHtml += `<tr><td>${c.column_name}</td><td>${c.data_type}</td><td>${c.is_nullable}</td></tr>`;
});
colHtml += '</table></div>';
// sample rows
let rowHtml = '';
if (data.rows.length > 0) {
const cols = Object.keys(data.rows[0]);
rowHtml = `<div class="preview-section"><h4>Sample rows</h4><table class="preview-table"><tr>`;
cols.forEach(c => rowHtml += `<th>${c}</th>`);
rowHtml += '</tr>';
data.rows.forEach(row => {
rowHtml += '<tr>';
cols.forEach(c => rowHtml += `<td>${row[c] ?? ''}</td>`);
rowHtml += '</tr>';
});
rowHtml += '</table></div>';
}
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 => `<span class="date-chip">${fmt.format(m)}</span>`).join('');
}
return `<span class="date-chip-summary">${months.length} months — ${fmt.format(months[0])}${fmt.format(months[months.length - 1])}</span>`;
}
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 = '<div class="segments-empty">No segments loaded yet.</div>';
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 `
<div class="segment-card">
<div class="segment-card-header">
<span class="segment-card-note">${s.note || '(no description)'}</span>
<span class="segment-card-meta">${s.pf_user}${stamp}</span>
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
</div>
<div class="segment-card-params">${filters}${offset}</div>
</div>`;
}).join('');
}
function addFilterRow() {
const container = document.getElementById('seg-filter-rows');
const idx = container.children.length;
const colOptions = state.baselineFilterCols
.map(c => `<option value="${c.cname}">${c.label || c.cname}</option>`)
.join('');
const div = document.createElement('div');
div.className = 'filter-row';
div.dataset.index = idx;
div.innerHTML = `
<select class="filter-col-select">
<option value="">— column —</option>
${colOptions}
</select>
<select class="filter-op-select">
<option value="=">=</option>
<option value="!=">!=</option>
<option value="IN">IN</option>
<option value="NOT IN">NOT IN</option>
<option value="BETWEEN" selected>BETWEEN</option>
<option value="IS NULL">IS NULL</option>
<option value="IS NOT NULL">IS NOT NULL</option>
</select>
<div class="filter-value-container"></div>
<button class="btn btn-sm btn-danger filter-remove-btn">×</button>
`;
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 = `
<input type="text" class="filter-val-from" placeholder="from" />
<span class="filter-between-sep">and</span>
<input type="text" class="filter-val-to" placeholder="to" />
`;
container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview));
return;
}
if (op === 'IN' || op === 'NOT IN') {
container.innerHTML = `<input type="text" class="filter-val-list" placeholder="a, b, c" />`;
return;
}
// = or !=
container.innerHTML = `<input type="text" class="filter-val-single" placeholder="value" />`;
}
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 = `
<div class="timeline-row">
<div class="timeline-row-label">Source</div>
<div class="timeline-bar-wrap">
<div class="timeline-bar"></div>
<div class="timeline-bar-labels">
<span>${fmt.format(from)}</span>
<span>${months} month${months !== 1 ? 's' : ''}</span>
<span>${fmt.format(to)}</span>
</div>
</div>
</div>`;
if (offsetType && offsetValue) {
html += `
<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} →</div>
<div class="timeline-row">
<div class="timeline-row-label">Projected</div>
<div class="timeline-bar-wrap">
<div class="timeline-bar timeline-bar-projected"></div>
<div class="timeline-bar-labels">
<span>${fmt.format(projFrom)}</span>
<span>${months} month${months !== 1 ? 's' : ''}</span>
<span>${fmt.format(projTo)}</span>
</div>
</div>
</div>`;
}
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 = '<span class="op-hint">Click a row to select a slice</span>';
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]) => `<span class="slice-tag">${k} = ${v}</span>`)
.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 <select>, others get a free-text input
async function renderDimFields(op) {
const container = document.getElementById(`${op}-fields`);
const dims = state.colMeta.filter(c => c.role === 'dimension');
// fetch values for all key columns in parallel
const keyDims = dims.filter(c => c.is_key);
const valueMap = {};
await Promise.all(keyDims.map(async c => {
try {
const vals = await api('GET', `/sources/${state.source.id}/values/${encodeURIComponent(c.cname)}`);
valueMap[c.cname] = vals;
} catch (_) {
valueMap[c.cname] = [];
}
}));
container.innerHTML = dims.map(c => {
const current = state.slice[c.cname] ? `current: ${state.slice[c.cname]}` : '';
const hint = current ? `<span class="field-hint">${current}</span>` : '';
let input;
if (c.is_key && valueMap[c.cname]?.length) {
const options = valueMap[c.cname]
.map(v => `<option value="${v}">${v}</option>`)
.join('');
input = `<select data-col="${c.cname}"><option value="">— keep current —</option>${options}</select>`;
} else {
input = `<input type="text" data-col="${c.cname}" placeholder="new value (leave blank to keep)" />`;
}
return `
<div class="${op}-field">
<label>${c.label || c.cname}${hint}${input}</label>
</div>`;
}).join('');
}
/* ============================================================
FORECAST VIEW — submit operations
============================================================ */
async function submitScale() {
const value_incr = parseFloat(document.getElementById('scale-value-incr').value) || null;
const units_incr = parseFloat(document.getElementById('scale-units-incr').value) || null;
const pct = document.getElementById('scale-pct').checked;
const note = document.getElementById('scale-note').value.trim();
if (!value_incr && !units_incr) {
showStatus('Enter a value or units increment', 'error'); return;
}
if (Object.keys(state.slice).length === 0) {
showStatus('Select a slice first', 'error'); return;
}
try {
const result = await api('POST', `/versions/${state.version.id}/scale`, {
pf_user: getPfUser(), note: note || undefined,
slice: state.slice, value_incr, units_incr, pct
});
showStatus(`Scale applied — ${result.rows_affected} rows inserted`, 'success');
document.getElementById('scale-value-incr').value = '';
document.getElementById('scale-units-incr').value = '';
document.getElementById('scale-note').value = '';
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}
}
async function submitRecode() {
const set = {};
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(inp => {
if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim();
});
if (Object.keys(set).length === 0) {
showStatus('Enter at least one new dimension value', 'error'); return;
}
if (Object.keys(state.slice).length === 0) {
showStatus('Select a slice first', 'error'); return;
}
try {
const result = await api('POST', `/versions/${state.version.id}/recode`, {
pf_user: getPfUser(),
note: document.getElementById('recode-note').value.trim() || undefined,
slice: state.slice,
set
});
showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success');
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; });
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}
}
async function submitClone() {
const set = {};
document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(inp => {
if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim();
});
if (Object.keys(set).length === 0) {
showStatus('Enter at least one new dimension value', 'error'); return;
}
if (Object.keys(state.slice).length === 0) {
showStatus('Select a slice first', 'error'); return;
}
try {
const scale = parseFloat(document.getElementById('clone-scale').value) || 1.0;
const result = await api('POST', `/versions/${state.version.id}/clone`, {
pf_user: getPfUser(),
note: document.getElementById('clone-note').value.trim() || undefined,
slice: state.slice,
set,
scale
});
showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success');
document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; });
document.getElementById('clone-scale').value = '1';
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
LOG VIEW
============================================================ */
async function loadLogData() {
if (!state.version) return;
try {
const logs = await api('GET', `/versions/${state.version.id}/log`);
renderLogGrid(logs);
} catch (err) {
showStatus(err.message, 'error');
}
}
function renderLogGrid(logs) {
const el = document.getElementById('log-grid');
const colDefs = [
{ field: 'id', headerName: 'ID', width: 65 },
{ field: 'pf_user', headerName: 'User', width: 90 },
{ field: 'stamp', headerName: 'Time', width: 140,
valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' },
{ field: 'operation', headerName: 'Operation', width: 90 },
{ field: 'slice', headerName: 'Slice', flex: 1,
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
{ field: 'params', headerName: 'Params', flex: 1,
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
{ field: 'note', headerName: 'Note', flex: 1 },
{
headerName: '',
width: 70,
cellRenderer: p => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-danger';
btn.textContent = 'Undo';
btn.dataset.logid = p.data.id;
return btn;
},
sortable: false
}
];
if (state.grids.log) {
state.grids.log.setGridOption('rowData', logs);
state.grids.log.setGridOption('columnDefs', colDefs);
return;
}
state.grids.log = agGrid.createGrid(el, {
columnDefs: colDefs,
rowData: logs,
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32,
rowHeight: 28
});
}
async function undoOperation(logid) {
if (!confirm(`Undo operation ${logid}? This will delete the associated forecast rows.`)) return;
try {
const result = await api('DELETE', `/log/${logid}`);
showStatus(`Undone — ${result.rows_deleted} rows removed`, 'success');
await loadLogData();
// refresh forecast grid if open
if (state.view === 'forecast') await loadForecastData();
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
INIT
============================================================ */
document.addEventListener('DOMContentLoaded', () => {
// restore pf_user from localStorage
const savedUser = localStorage.getItem('pf_user');
if (savedUser) document.getElementById('input-pf-user').value = savedUser;
document.getElementById('input-pf-user').addEventListener('change', e => {
localStorage.setItem('pf_user', e.target.value.trim());
});
// navigation
document.querySelectorAll('.nav-links li').forEach(li => {
li.addEventListener('click', () => switchView(li.dataset.view));
});
// sources view buttons
document.getElementById('btn-register').addEventListener('click', () => {
if (state.previewSchema && state.previewTname) {
registerTable(state.previewSchema, state.previewTname);
}
});
document.getElementById('btn-back-sources').addEventListener('click', backToSources);
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
// modal
document.getElementById('modal-close').addEventListener('click', () =>
document.getElementById('modal-overlay').classList.add('hidden')
);
document.getElementById('btn-modal-close').addEventListener('click', () =>
document.getElementById('modal-overlay').classList.add('hidden')
);
document.getElementById('btn-modal-register').addEventListener('click', () => {
if (state.previewSchema && state.previewTname) {
registerTable(state.previewSchema, state.previewTname);
}
});
// versions view buttons
document.getElementById('btn-new-version').addEventListener('click', showNewVersionForm);
document.getElementById('btn-create-version').addEventListener('click', createVersion);
document.getElementById('btn-cancel-version').addEventListener('click', () => {
document.getElementById('new-version-form').classList.add('hidden');
});
document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
document.getElementById('vbtn-baseline').addEventListener('click', openVersionBaseline);
document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference'));
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
document.getElementById('btn-load-submit').addEventListener('click', submitLoadData);
document.getElementById('btn-load-cancel').addEventListener('click', hideLoadModal);
document.getElementById('btn-load-close').addEventListener('click', hideLoadModal);
document.getElementById('load-date-from').addEventListener('change', updateDatePreview);
document.getElementById('load-date-to').addEventListener('change', updateDatePreview);
document.getElementById('load-offset-years').addEventListener('input', updateDatePreview);
document.getElementById('load-offset-months').addEventListener('input', updateDatePreview);
// forecast view buttons
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);
document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll());
document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll());
document.getElementById('btn-clear-slice').addEventListener('click', clearSlice);
document.querySelectorAll('.op-tab').forEach(tab => {
tab.addEventListener('click', () => switchOpTab(tab.dataset.op));
});
document.getElementById('btn-submit-scale').addEventListener('click', submitScale);
document.getElementById('btn-submit-recode').addEventListener('click', submitRecode);
document.getElementById('btn-submit-clone').addEventListener('click', submitClone);
// undo button delegation on log grid
document.getElementById('log-grid').addEventListener('click', e => {
const btn = e.target.closest('.btn-danger[data-logid]');
if (btn) undoOperation(parseInt(btn.dataset.logid));
});
// baseline workbench
document.getElementById('btn-add-filter-row').addEventListener('click', addFilterRow);
document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment);
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
document.getElementById('seg-offset-value').addEventListener('input', updateTimelinePreview);
// undo in baseline segments list
document.getElementById('baseline-segments-list').addEventListener('click', async e => {
const btn = e.target.closest('[data-logid]');
if (!btn) return;
const logid = parseInt(btn.dataset.logid);
const result = await api('DELETE', `/log/${logid}`);
showStatus(`Segment undone — ${result.rows_deleted} rows removed`, 'success');
await loadBaselineSegments();
});
// init sources view
initSourcesView().catch(err => showStatus(err.message, 'error'));
});