Add baseline workbench — multi-segment additive baseline with filter builder

- New Baseline nav view replaces the simple Load Baseline modal
- Baseline loads are now additive; each segment is independently undoable
- Filter builder: any date/filter-role column, full operator set
- Timeline preview shows source → projected period bars for date BETWEEN filters
- Clear Baseline action deletes all baseline rows and log entries
- DELETE /api/versions/:id/baseline route
- buildFilterClause() added to sql_generator
- filter role added to col_meta editor
- Reminder: re-run generate-sql for each source after this change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-01 13:27:36 -04:00
parent 7e9ea456b6
commit 5550a57f97
5 changed files with 616 additions and 24 deletions

View File

@ -59,14 +59,11 @@ ilog AS (
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}') VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
RETURNING id RETURNING id
) )
,del AS (
DELETE FROM {{fc_table}} WHERE iter = 'baseline'
)
,ins AS ( ,ins AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${insertCols})
SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now() SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable} FROM ${srcTable}
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' WHERE {{filter_clause}}
RETURNING * RETURNING *
) )
SELECT count(*) AS rows_affected FROM ins`.trim(); SELECT count(*) AS rows_affected FROM ins`.trim();
@ -248,10 +245,44 @@ function buildSetClause(dimCols, setObj) {
}).join(', '); }).join(', ');
} }
// build a SQL WHERE clause from an array of filter objects { col, op, values }
// only allows columns with role 'date' or 'filter'
function buildFilterClause(filters, colMeta) {
if (!filters || filters.length === 0) {
const err = new Error('At least one filter is required');
err.status = 400; throw err;
}
const allowed = new Set(
colMeta.filter(c => c.role === 'date' || c.role === 'filter').map(c => c.cname)
);
const parts = filters.map(({ col, op, values = [] }) => {
if (!allowed.has(col)) {
const err = new Error(`Column "${col}" is not available for baseline filtering`);
err.status = 400; throw err;
}
const c = `"${col}"`;
const v = values.map(x => `'${esc(String(x))}'`);
switch (op) {
case '=': return `${c} = ${v[0]}`;
case '!=': return `${c} != ${v[0]}`;
case 'IN': return `${c} IN (${v.join(', ')})`;
case 'NOT IN': return `${c} NOT IN (${v.join(', ')})`;
case 'BETWEEN': return `${c} BETWEEN ${v[0]} AND ${v[1]}`;
case 'IS NULL': return `${c} IS NULL`;
case 'IS NOT NULL': return `${c} IS NOT NULL`;
default: {
const err = new Error(`Unsupported operator "${op}"`);
err.status = 400; throw err;
}
}
});
return parts.join('\nAND ');
}
// escape a value for safe SQL string substitution // escape a value for safe SQL string substitution
function esc(val) { function esc(val) {
if (val === null || val === undefined) return ''; if (val === null || val === undefined) return '';
return String(val).replace(/'/g, "''"); return String(val).replace(/'/g, "''");
} }
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc }; module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc };

View File

@ -9,6 +9,7 @@ const state = {
colMeta: [], // col_meta for selected source colMeta: [], // col_meta for selected source
slice: {}, // current pivot selection slice: {}, // current pivot selection
loadDataOp: null, // 'baseline' | 'reference' loadDataOp: null, // 'baseline' | 'reference'
baselineFilterCols: [],
previewSchema: null, previewSchema: null,
previewTname: null, previewTname: null,
grids: { grids: {
@ -50,7 +51,7 @@ function showStatus(msg, type = 'info') {
NAVIGATION NAVIGATION
============================================================ */ ============================================================ */
function switchView(name) { function switchView(name) {
if ((name === 'forecast' || name === 'log') && !state.version) { if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) {
showStatus('Select a version first', 'error'); showStatus('Select a version first', 'error');
return; return;
} }
@ -68,6 +69,7 @@ function switchView(name) {
if (name === 'versions') renderVersions(); if (name === 'versions') renderVersions();
if (name === 'forecast') loadForecastData(); if (name === 'forecast') loadForecastData();
if (name === 'log') loadLogData(); if (name === 'log') loadLogData();
if (name === 'baseline') openBaselineWorkbench();
} }
function setSource(source) { function setSource(source) {
@ -245,9 +247,9 @@ function renderColMetaGrid(colMeta) {
{ {
field: 'role', headerName: 'Role', width: 110, editable: true, field: 'role', headerName: 'Role', width: 110, editable: true,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] }, cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] },
cellStyle: p => { cellStyle: p => {
const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', ignore: '#f9f9f9' }; const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', filter: '#fef5e4', ignore: '#f9f9f9' };
return { background: colors[p.value] || '' }; return { background: colors[p.value] || '' };
} }
}, },
@ -528,6 +530,290 @@ function openForecast() {
switchView('forecast'); 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-years').value = '0';
document.getElementById('seg-offset-months').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 offsetYears = parseInt(document.getElementById('seg-offset-years').value) || 0;
const offsetMonths = parseInt(document.getElementById('seg-offset-months').value) || 0;
const projFrom = new Date(from); projFrom.setFullYear(projFrom.getFullYear() + offsetYears); projFrom.setMonth(projFrom.getMonth() + offsetMonths);
const projTo = new Date(to); projTo.setFullYear(projTo.getFullYear() + offsetYears); projTo.setMonth(projTo.getMonth() + offsetMonths);
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 (offsetYears || offsetMonths) {
const parts = [];
if (offsetYears) parts.push(`${offsetYears} yr`);
if (offsetMonths) parts.push(`${offsetMonths} mo`);
html += `
<div class="timeline-offset-indicator">+ ${parts.join(' ')} </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 years = parseInt(document.getElementById('seg-offset-years').value) || 0;
const months = parseInt(document.getElementById('seg-offset-months').value) || 0;
const parts = [];
if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
const date_offset = parts.length ? parts.join(' ') : '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 FORECAST VIEW data loading
============================================================ */ ============================================================ */
@ -956,7 +1242,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('new-version-form').classList.add('hidden'); document.getElementById('new-version-form').classList.add('hidden');
}); });
document.getElementById('vbtn-forecast').addEventListener('click', openForecast); document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
document.getElementById('vbtn-baseline').addEventListener('click', () => showLoadForm('baseline')); document.getElementById('vbtn-baseline').addEventListener('click', openVersionBaseline);
document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference')); document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference'));
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus); document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion); document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
@ -988,6 +1274,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (btn) undoOperation(parseInt(btn.dataset.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-years').addEventListener('input', updateTimelinePreview);
document.getElementById('seg-offset-months').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 // init sources view
initSourcesView().catch(err => showStatus(err.message, 'error')); initSourcesView().catch(err => showStatus(err.message, 'error'));
}); });

View File

@ -17,6 +17,7 @@
<ul class="nav-links"> <ul class="nav-links">
<li data-view="sources" class="active">Sources</li> <li data-view="sources" class="active">Sources</li>
<li data-view="versions">Versions</li> <li data-view="versions">Versions</li>
<li data-view="baseline">Baseline</li>
<li data-view="forecast">Forecast</li> <li data-view="forecast">Forecast</li>
<li data-view="log">Log</li> <li data-view="log">Log</li>
</ul> </ul>
@ -96,6 +97,45 @@
</div> </div>
</div> </div>
<!-- ===== BASELINE WORKBENCH VIEW ===== -->
<div id="view-baseline" class="view hidden">
<div class="workbench-toolbar">
<span id="baseline-label">No version selected</span>
<button id="btn-clear-baseline" class="btn btn-danger">Clear Baseline</button>
</div>
<div class="baseline-layout">
<div class="baseline-form-panel">
<div class="panel-section-title">Add Segment</div>
<div class="baseline-form">
<label class="baseline-field-label">Description
<input type="text" id="seg-description" placeholder="e.g. All orders FY2024" />
</label>
<div class="offset-row">
<label class="baseline-field-label">Offset Years
<input type="number" id="seg-offset-years" min="0" value="0" />
</label>
<label class="baseline-field-label">Offset Months
<input type="number" id="seg-offset-months" min="0" value="0" />
</label>
</div>
<div class="filter-section">
<div class="filter-section-label">Filters <span class="required-star">*</span></div>
<div id="seg-filter-rows"></div>
<button id="btn-add-filter-row" class="btn btn-sm">+ Add Filter</button>
</div>
<div id="seg-timeline" class="timeline-preview hidden"></div>
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>
</div>
</div>
<div class="baseline-segments-panel">
<div class="panel-section-title">Loaded Segments</div>
<div id="baseline-segments-list">
<div class="segments-empty">No segments loaded yet.</div>
</div>
</div>
</div>
</div>
<!-- ===== FORECAST VIEW ===== --> <!-- ===== FORECAST VIEW ===== -->
<div id="view-forecast" class="view hidden"> <div id="view-forecast" class="view hidden">
<div class="forecast-toolbar"> <div class="forecast-toolbar">

View File

@ -411,3 +411,191 @@ body {
.preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; } .preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; }
.preview-table th { background: #f5f7f9; font-weight: 600; } .preview-table th { background: #f5f7f9; font-weight: 600; }
.preview-table tr:hover td { background: #fafbfc; } .preview-table tr:hover td { background: #fafbfc; }
/* ============================================================
BASELINE WORKBENCH
============================================================ */
.workbench-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#baseline-label { font-weight: 600; font-size: 13px; flex: 1; }
.baseline-layout {
display: flex;
gap: 10px;
flex: 1;
overflow: hidden;
min-height: 0;
}
.baseline-form-panel {
width: 360px;
flex-shrink: 0;
background: white;
border-radius: 5px;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.baseline-segments-panel {
flex: 1;
background: white;
border-radius: 5px;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.panel-section-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: #7f8c8d;
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.baseline-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.baseline-field-label {
font-size: 11px;
color: #555;
display: flex;
flex-direction: column;
gap: 3px;
}
.baseline-field-label input[type=text],
.baseline-field-label input[type=number] {
border: 1px solid #dce1e7;
padding: 5px 8px;
border-radius: 3px;
font-size: 12px;
color: #333;
}
.offset-row { display: flex; gap: 10px; }
.offset-row label { flex: 1; }
.filter-section { display: flex; flex-direction: column; gap: 6px; }
.filter-section-label {
font-size: 11px;
color: #555;
font-weight: 600;
}
.required-star { color: #e74c3c; }
.filter-row {
display: flex;
gap: 4px;
align-items: flex-start;
flex-wrap: wrap;
padding: 6px 8px;
background: #f8fafc;
border: 1px solid #e8ecf0;
border-radius: 3px;
margin-bottom: 4px;
}
.filter-row select,
.filter-row input[type=text] {
border: 1px solid #dce1e7;
padding: 4px 6px;
border-radius: 3px;
font-size: 11px;
color: #333;
background: white;
}
.filter-col-select { min-width: 120px; }
.filter-op-select { min-width: 90px; }
.filter-val-single,
.filter-val-list { min-width: 120px; flex: 1; }
.filter-val-from,
.filter-val-to { min-width: 90px; flex: 1; }
.filter-between-sep { font-size: 11px; color: #aaa; padding: 5px 2px; }
.filter-value-container { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
/* Timeline preview */
.timeline-preview { display: flex; flex-direction: column; gap: 6px; padding: 10px 0 4px; }
.timeline-preview.hidden { display: none; }
.timeline-row { display: flex; align-items: center; gap: 8px; }
.timeline-row-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: #7f8c8d;
letter-spacing: 0.04em;
width: 60px;
flex-shrink: 0;
text-align: right;
}
.timeline-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 3px; }
.timeline-bar {
height: 14px;
background: #2980b9;
border-radius: 3px;
width: 100%;
}
.timeline-bar-projected { background: #27ae60; }
.timeline-bar-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #666;
}
.timeline-offset-indicator {
font-size: 11px;
color: #aaa;
padding: 2px 0 2px 68px;
font-style: italic;
}
/* Segment cards */
.segment-card {
border: 1px solid #e8ecf0;
border-radius: 4px;
padding: 10px 12px;
background: #fafbfc;
display: flex;
flex-direction: column;
gap: 4px;
}
.segment-card-header {
display: flex;
align-items: center;
gap: 8px;
}
.segment-card-note {
font-size: 12px;
font-weight: 600;
flex: 1;
}
.segment-card-meta {
font-size: 11px;
color: #888;
}
.segment-card-params {
font-size: 11px;
color: #555;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
}
.segments-empty { font-size: 12px; color: #aaa; font-style: italic; }

View File

@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator'); const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc } = require('../lib/sql_generator');
const { fcTable } = require('../lib/utils'); const { fcTable } = require('../lib/utils');
module.exports = function(pool) { module.exports = function(pool) {
@ -74,12 +74,11 @@ module.exports = function(pool) {
} }
}); });
// load baseline rows from source table for a date range // load baseline rows from source table — additive, no delete
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
router.post('/versions/:id/baseline', async (req, res) => { router.post('/versions/:id/baseline', async (req, res) => {
const { date_from, date_to, date_offset, pf_user, note, replay } = req.body; const { filters, date_offset, pf_user, note, replay } = req.body;
if (!date_from || !date_to) { if (!filters || filters.length === 0) {
return res.status(400).json({ error: 'date_from and date_to are required' }); return res.status(400).json({ error: 'filters are required' });
} }
if (replay) { if (replay) {
return res.status(501).json({ error: 'replay is not yet implemented' }); return res.status(501).json({ error: 'replay is not yet implemented' });
@ -89,19 +88,50 @@ module.exports = function(pool) {
const ctx = await getContext(parseInt(req.params.id), 'baseline'); const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
const filterClause = buildFilterClause(filters, ctx.colMeta);
const sql = applyTokens(ctx.sql, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ date_from, date_to, date_offset: dateOffset })), params: esc(JSON.stringify({ filters, date_offset: dateOffset })),
date_from: esc(date_from), filter_clause: filterClause,
date_to: esc(date_to), date_offset: esc(dateOffset)
date_offset: esc(dateOffset)
}); });
const result = await runSQL(sql); const result = await runSQL(sql);
res.json({ rows_affected: result.rows.length }); res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// delete all baseline rows and log entries for a version
router.delete('/versions/:id/baseline', async (req, res) => {
const versionId = parseInt(req.params.id);
try {
const ctx = await getContext(versionId, 'baseline');
if (!guardOpen(ctx.version, res)) return;
const client = await pool.connect();
try {
await client.query('BEGIN');
const delRows = await client.query(
`DELETE FROM ${ctx.table} WHERE iter = 'baseline' RETURNING id`
);
const delLog = await client.query(
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,
[versionId]
);
await client.query('COMMIT');
res.json({ rows_deleted: delRows.rowCount, log_entries_deleted: delLog.rowCount });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(err.status || 500).json({ error: err.message }); res.status(err.status || 500).json({ error: err.message });