Reference offsets, edit visual cues, todo updates

Reference segments can now apply a date offset just like baselines.
SQL template gains the {{date_offset}} token; both POST /reference and
PUT baseline/:logid pass it through. Existing sources need to
regenerate SQL to pick up the new template — old stored reference SQL
ignores the token (preserving prior verbatim behavior). The Baseline
form drops the "dates land verbatim" hint and shows the offset
control for both segment types.

Editing a segment now color-codes the source row amber with a ring
and tints the form border + header amber so the active connection is
visually obvious. Header label reads "Edit segment #3 — baseline —
note" instead of just "#43" (the internal log id).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-29 11:08:49 -04:00
parent 4fde752b54
commit 953ae2709f
4 changed files with 53 additions and 61 deletions

View File

@ -69,6 +69,9 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildReference() {
const referenceSelect = dataCols.map(c =>
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
).join(', ');
return `
WITH
ilog AS (
@ -78,7 +81,7 @@ ilog AS (
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
SELECT ${referenceSelect}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE {{filter_clause}}
RETURNING *

View File

@ -192,11 +192,10 @@ module.exports = function(pool) {
const paramsJson = JSON.stringify({
where_clause: filterClause,
...(oldLog.operation === 'baseline' ? { date_offset: dateOffset } : {}),
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const tokens = oldLog.operation === 'baseline'
? {
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
@ -204,16 +203,7 @@ module.exports = function(pool) {
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
}
: {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
filter_clause: filterClause
};
const sql = applyTokens(ctx.sql, tokens);
});
await client.query('BEGIN');
const delRows = await client.query(
@ -274,13 +264,15 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, pf_user, note, filters, raw_where } = req.body;
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
@ -289,7 +281,8 @@ module.exports = function(pool) {
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
filter_clause: filterClause
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
const result = await runSQL(sql);

11
todo.md
View File

@ -1,4 +1,7 @@
- [ ] should be able to edit and revise forecase segments that constitute baseline or reference. if you edit, maybe a warning that your forecast values wont mean a lot, and have an option to delete them.
- [ ] when you enter the forecast, be able to enter in a context so you dont have to open the whole thing (should show in status bar and be a filter for SQL and spi calls)
- [x] should be able to edit and revise forecase segments that constitute baseline or reference. if you edit, maybe a warning that your forecast values wont mean a lot, and have an option to delete them.
Notes: A baseline/reference segment is a `pf.log` row plus the
forecast rows it produced (joined by pf_logid). Editing has the
@ -20,7 +23,7 @@
Implementation order: API + cascade detection first (compare
pf.log.stamp ordering); UI second.
- [ ] be able to copy an existing forecast and it's segments to adjust some parameters without having to start from scrath.
- [~] be able to copy an existing forecast and it's segments to adjust some parameters without having to start from scrath.
Notes: A version is the unit of copy. Need a `POST /versions/:id/copy`
endpoint that creates a new pf.version row with the same source/
@ -43,7 +46,7 @@
- Should the copy track its origin? A `parent_version_id` column
on pf.version makes "show me variants of FY2026 Plan" easy.
- [ ] need the list of filters to have an and/or specification
- [x] need the list of filters to have an and/or specification
Notes: Spec already covers this in `pf_spec.md:245``filters` is
an array of groups; conditions within a group are AND-ed, groups
@ -58,7 +61,7 @@
already has a single-group filter builder; extend it to wrap the
current rows in a group container and allow adding more groups.
- [ ] the filters should have the option to just write the WHERE clause SQL
- [x] the filters should have the option to just write the WHERE clause SQL
Notes: Spec covers this too (`pf_spec.md:251`, `:454`) as the
`raw_where` admin-only escape hatch. The current baseline endpoint

View File

@ -144,15 +144,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = isRef
? '0 days'
: ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
...(isRef ? {} : { date_offset: offsetStr }),
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
}
setSubmitting(true)
@ -189,13 +187,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
setSegType(entry.operation)
setSegNote(entry.note || '')
setDescription('')
if (entry.operation === 'baseline') {
const off = parseOffset(params.date_offset)
setOffsetYr(off.yr)
setOffsetMo(off.mo)
} else {
setOffsetYr(0); setOffsetMo(0)
}
const groups = normalizeFilters(params.filters)
if (groups) {
setUseRaw(false)
@ -374,7 +368,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<tr
key={entry.id}
onClick={() => setExpandedId(isOpen ? null : entry.id)}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : isOpen ? 'bg-blue-50' : ''}`}
>
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
@ -417,14 +411,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
{/* Add / Edit Segment */}
{(showAddForm || editingLogId) && (
<div id="add-segment" className="bg-white border border-gray-200 rounded">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<div id="add-segment" className={`bg-white border rounded ${editingLogId ? 'border-amber-300' : 'border-gray-200'}`}>
<div className={`px-3 py-2 border-b text-xs font-medium uppercase tracking-wide flex items-center justify-between ${editingLogId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
<span>{(() => {
if (!editingLogId) return 'Add Segment'
const entry = log.find(e => e.id === editingLogId)
if (!entry) return 'Edit Segment'
const idx = log.findIndex(e => e.id === editingLogId)
if (idx < 0) return 'Edit Segment'
const entry = log[idx]
const segNum = log.length - idx
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
return entry.note ? `Edit ${label}${entry.note}` : `Edit ${label} segment`
return entry.note
? `Edit segment #${segNum}${label}${entry.note}`
: `Edit segment #${segNum}${label}`
})()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'}
@ -549,9 +547,6 @@ function SegmentForm({
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description (edit only) */}
@ -657,8 +652,7 @@ function SegmentForm({
)}
</div>
{/* Date offset — baseline only */}
{segType === 'baseline' && (
{/* Date offset */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<div className="flex items-center gap-2">
@ -668,7 +662,6 @@ function SegmentForm({
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
@ -677,8 +670,8 @@ function SegmentForm({
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
offsetYr={offsetYr}
offsetMo={offsetMo}
type={segType}
/>
</div>