Add date offset to baseline — project actuals into forecast period

The baseline operation now accepts a date_offset interval (e.g. "1 year",
"6 months") and applies it to every date when inserting rows, shifting
historical actuals into the target forecast period.

SQL: {date_col} + '{{date_offset}}'::interval)::date at insert time.
Route: defaults to '0 days' if omitted so existing calls are unaffected.
UI: year/month spinners with a live before→after month chip preview so
the projected landing period is visible before submitting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-01 12:48:28 -04:00
parent ddd16bc7a0
commit 6d8b052eb6
5 changed files with 117 additions and 29 deletions

View File

@ -49,6 +49,9 @@ function generateSQL(source, colMeta) {
} }
function buildBaseline() { function buildBaseline() {
const baselineSelect = dataCols.map(c =>
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
).join(', ');
return ` return `
WITH WITH
ilog AS ( ilog AS (
@ -61,7 +64,7 @@ ilog AS (
) )
,ins AS ( ,ins AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, '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 ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
RETURNING * RETURNING *

View File

@ -375,10 +375,16 @@ function showLoadForm(op) {
state.loadDataOp = op; state.loadDataOp = op;
document.getElementById('load-data-title').textContent = document.getElementById('load-data-title').textContent =
op === 'baseline' ? 'Load Baseline' : 'Load Reference'; op === 'baseline' ? 'Load Baseline' : 'Load Reference';
document.getElementById('load-date-from').value = ''; document.getElementById('load-date-from').value = '';
document.getElementById('load-date-to').value = ''; document.getElementById('load-date-to').value = '';
document.getElementById('load-note').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'); 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-data-modal').classList.remove('hidden');
document.getElementById('load-date-from').focus(); document.getElementById('load-date-from').focus();
} }
@ -387,31 +393,58 @@ function hideLoadModal() {
document.getElementById('load-data-modal').classList.add('hidden'); document.getElementById('load-data-modal').classList.add('hidden');
} }
function updateDatePreview() { function buildMonthList(fromVal, toVal) {
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 from = new Date(fromVal + 'T00:00:00');
const to = new Date(toVal + 'T00:00:00'); const to = new Date(toVal + 'T00:00:00');
if (isNaN(from) || isNaN(to) || from > to) { preview.classList.add('hidden'); return; } if (isNaN(from) || isNaN(to) || from > to) return null;
const months = []; const months = [];
const cur = new Date(from.getFullYear(), from.getMonth(), 1); const cur = new Date(from.getFullYear(), from.getMonth(), 1);
const end = new Date(to.getFullYear(), to.getMonth(), 1); const end = new Date(to.getFullYear(), to.getMonth(), 1);
while (cur <= end) { months.push(new Date(cur)); cur.setMonth(cur.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' }); 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) { if (state.loadDataOp === 'baseline') {
chips.innerHTML = months.map(m => `<span class="date-chip">${fmt.format(m)}</span>`).join(''); 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 { } else {
chips.innerHTML = `<span class="date-chip-summary">${months.length} months — ${fmt.format(months[0])}${fmt.format(months[months.length - 1])}</span>`; 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'); preview.classList.remove('hidden');
@ -429,6 +462,15 @@ async function submitLoadData() {
note: document.getElementById('load-note').value.trim() || undefined 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 { try {
showStatus(`Loading ${state.loadDataOp}...`, 'info'); showStatus(`Loading ${state.loadDataOp}...`, 'info');
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body); const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
@ -923,6 +965,8 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-load-close').addEventListener('click', hideLoadModal); document.getElementById('btn-load-close').addEventListener('click', hideLoadModal);
document.getElementById('load-date-from').addEventListener('change', updateDatePreview); document.getElementById('load-date-from').addEventListener('change', updateDatePreview);
document.getElementById('load-date-to').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 // forecast view buttons
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData); document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);

View File

@ -169,11 +169,34 @@
<div class="load-form-fields"> <div class="load-form-fields">
<label>Date From<input type="date" id="load-date-from" /></label> <label>Date From<input type="date" id="load-date-from" /></label>
<label>Date To<input type="date" id="load-date-to" /></label> <label>Date To<input type="date" id="load-date-to" /></label>
<div id="load-offset-fields">
<div class="load-offset-row">
<label>Offset Years<input type="number" id="load-offset-years" min="0" value="0" /></label>
<label>Offset Months<input type="number" id="load-offset-months" min="0" value="0" /></label>
</div>
</div>
<label>Note<input type="text" id="load-note" placeholder="optional" /></label> <label>Note<input type="text" id="load-note" placeholder="optional" /></label>
</div> </div>
<div id="load-date-preview" class="load-date-preview hidden"> <div id="load-date-preview" class="load-date-preview hidden">
<div class="load-preview-label"></div> <!-- reference: single chip list -->
<div id="load-date-chips" class="date-chips"></div> <div id="load-preview-simple">
<div class="load-preview-label"></div>
<div id="load-date-chips" class="date-chips"></div>
</div>
<!-- baseline: before → after -->
<div id="load-preview-offset" class="hidden">
<div class="load-preview-columns">
<div class="load-preview-col">
<div class="load-preview-label">Source</div>
<div id="load-chips-source" class="date-chips"></div>
</div>
<div class="load-preview-arrow"></div>
<div class="load-preview-col">
<div class="load-preview-label">Projected</div>
<div id="load-chips-projected" class="date-chips"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -356,9 +356,25 @@ body {
box-shadow: 0 0 0 2px rgba(41,128,185,.15); box-shadow: 0 0 0 2px rgba(41,128,185,.15);
} }
.load-offset-row { display: flex; gap: 12px; }
.load-offset-row label { flex: 1; }
.load-date-preview { display: flex; flex-direction: column; gap: 8px; } .load-date-preview { display: flex; flex-direction: column; gap: 8px; }
.load-date-preview.hidden { display: none; } .load-date-preview.hidden { display: none; }
.load-preview-columns {
display: flex;
gap: 10px;
align-items: flex-start;
}
.load-preview-col { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.load-preview-arrow {
font-size: 20px;
color: #aaa;
padding-top: 18px;
flex-shrink: 0;
}
.load-preview-label { .load-preview-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;

View File

@ -77,25 +77,27 @@ module.exports = function(pool) {
// load baseline rows from source table for a date range // load baseline rows from source table for a date range
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL) // 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, pf_user, note, replay } = req.body; const { date_from, date_to, date_offset, pf_user, note, replay } = req.body;
if (!date_from || !date_to) { if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' }); return res.status(400).json({ error: 'date_from and date_to 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' });
} }
const dateOffset = date_offset || '0 days';
try { try {
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 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 })), params: esc(JSON.stringify({ date_from, date_to, date_offset: dateOffset })),
date_from: esc(date_from), date_from: esc(date_from),
date_to: esc(date_to) date_to: esc(date_to),
date_offset: esc(dateOffset)
}); });
const result = await runSQL(sql); const result = await runSQL(sql);