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:
parent
ddd16bc7a0
commit
6d8b052eb6
@ -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 *
|
||||||
|
|||||||
@ -377,8 +377,14 @@ function showLoadForm(op) {
|
|||||||
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-offset-years').value = '0';
|
||||||
|
document.getElementById('load-offset-months').value = '0';
|
||||||
document.getElementById('load-note').value = '';
|
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);
|
||||||
|
|||||||
@ -169,12 +169,35 @@
|
|||||||
<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">
|
||||||
|
<!-- reference: single chip list -->
|
||||||
|
<div id="load-preview-simple">
|
||||||
<div class="load-preview-label"></div>
|
<div class="load-preview-label"></div>
|
||||||
<div id="load-date-chips" class="date-chips"></div>
|
<div id="load-date-chips" class="date-chips"></div>
|
||||||
</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 class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="btn-load-submit" class="btn btn-primary">Load</button>
|
<button id="btn-load-submit" class="btn btn-primary">Load</button>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -77,13 +77,14 @@ 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;
|
||||||
@ -93,9 +94,10 @@ module.exports = function(pool) {
|
|||||||
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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user