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() { function buildReference() {
const referenceSelect = dataCols.map(c =>
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
).join(', ');
return ` return `
WITH WITH
ilog AS ( ilog AS (
@ -78,7 +81,7 @@ ilog AS (
) )
,ins AS ( ,ins AS (
INSERT INTO {{fc_table}} (${insertCols}) 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} FROM ${srcTable}
WHERE {{filter_clause}} WHERE {{filter_clause}}
RETURNING * RETURNING *

View File

@ -192,28 +192,18 @@ module.exports = function(pool) {
const paramsJson = JSON.stringify({ const paramsJson = JSON.stringify({
where_clause: filterClause, where_clause: filterClause,
...(oldLog.operation === 'baseline' ? { date_offset: dateOffset } : {}), date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {})) ...(raw_where ? { raw_where } : (filters ? { filters } : {}))
}); });
const tokens = oldLog.operation === 'baseline' 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(paramsJson),
params: esc(paramsJson), filter_clause: filterClause,
filter_clause: filterClause, date_offset: esc(dateOffset)
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'); await client.query('BEGIN');
const delRows = await client.query( 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) // load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => { 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'; const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try { try {
const ctx = await getContext(parseInt(req.params.id), 'reference'); const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({ const paramsJson = JSON.stringify({
where_clause: filterClause, where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {})) ...(raw_where ? { raw_where } : (filters ? { filters } : {}))
}); });
const sql = applyTokens(ctx.sql, { const sql = applyTokens(ctx.sql, {
@ -289,7 +281,8 @@ module.exports = function(pool) {
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(paramsJson), params: esc(paramsJson),
filter_clause: filterClause filter_clause: filterClause,
date_offset: esc(dateOffset)
}); });
const result = await runSQL(sql); 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 Notes: A baseline/reference segment is a `pf.log` row plus the
forecast rows it produced (joined by pf_logid). Editing has the forecast rows it produced (joined by pf_logid). Editing has the
@ -20,7 +23,7 @@
Implementation order: API + cascade detection first (compare Implementation order: API + cascade detection first (compare
pf.log.stamp ordering); UI second. 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` 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/ 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 - Should the copy track its origin? A `parent_version_id` column
on pf.version makes "show me variants of FY2026 Plan" easy. 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 Notes: Spec already covers this in `pf_spec.md:245``filters` is
an array of groups; conditions within a group are AND-ed, groups 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 already has a single-group filter builder; extend it to wrap the
current rows in a group container and allow adding more groups. 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 Notes: Spec covers this too (`pf_spec.md:251`, `:454`) as the
`raw_where` admin-only escape hatch. The current baseline endpoint `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) const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return } if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
const isRef = segType === 'reference' const isRef = segType === 'reference'
const offsetStr = isRef const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
? '0 days'
: ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const endpoint = isRef ? 'reference' : 'baseline' const endpoint = isRef ? 'reference' : 'baseline'
const body = { const body = {
where_clause: clause, where_clause: clause,
pf_user: 'admin', pf_user: 'admin',
note: description || segNote, note: description || segNote,
...(isRef ? {} : { date_offset: offsetStr }), date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }), ...(useRaw ? { raw_where: clause } : { filters }),
} }
setSubmitting(true) setSubmitting(true)
@ -189,13 +187,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
setSegType(entry.operation) setSegType(entry.operation)
setSegNote(entry.note || '') setSegNote(entry.note || '')
setDescription('') setDescription('')
if (entry.operation === 'baseline') { const off = parseOffset(params.date_offset)
const off = parseOffset(params.date_offset) setOffsetYr(off.yr)
setOffsetYr(off.yr) setOffsetMo(off.mo)
setOffsetMo(off.mo)
} else {
setOffsetYr(0); setOffsetMo(0)
}
const groups = normalizeFilters(params.filters) const groups = normalizeFilters(params.filters)
if (groups) { if (groups) {
setUseRaw(false) setUseRaw(false)
@ -374,7 +368,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<tr <tr
key={entry.id} key={entry.id}
onClick={() => setExpandedId(isOpen ? null : 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 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> <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 */} {/* Add / Edit Segment */}
{(showAddForm || editingLogId) && ( {(showAddForm || editingLogId) && (
<div id="add-segment" className="bg-white border border-gray-200 rounded"> <div id="add-segment" className={`bg-white border rounded ${editingLogId ? 'border-amber-300' : 'border-gray-200'}`}>
<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 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>{(() => { <span>{(() => {
if (!editingLogId) return 'Add Segment' if (!editingLogId) return 'Add Segment'
const entry = log.find(e => e.id === editingLogId) const idx = log.findIndex(e => e.id === editingLogId)
if (!entry) return 'Edit Segment' if (idx < 0) return 'Edit Segment'
const label = entry.operation === 'reference' ? 'reference' : 'baseline' const entry = log[idx]
return entry.note ? `Edit ${label}${entry.note}` : `Edit ${label} segment` const segNum = log.length - idx
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
return entry.note
? `Edit segment #${segNum}${label}${entry.note}`
: `Edit segment #${segNum}${label}`
})()}</span> })()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal"> <button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'} {editingLogId ? 'Cancel edit' : 'Close'}
@ -549,9 +547,6 @@ function SegmentForm({
>{t}</button> >{t}</button>
))} ))}
</div> </div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div> </div>
{/* Description (edit only) */} {/* Description (edit only) */}
@ -657,18 +652,16 @@ function SegmentForm({
)} )}
</div> </div>
{/* Date offset — baseline only */} {/* Date offset */}
{segType === 'baseline' && ( <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} /> <span className="text-xs text-gray-500">yr</span>
<span className="text-xs text-gray-500">yr</span> <input disabled={disabled} type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<input disabled={disabled} type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} /> <span className="text-xs text-gray-500">mo</span>
<span className="text-xs text-gray-500">mo</span>
</div>
</div> </div>
)} </div>
{/* Timeline */} {/* Timeline */}
{dateRange && ( {dateRange && (
@ -677,8 +670,8 @@ function SegmentForm({
<Timeline <Timeline
dateFrom={dateRange.from} dateFrom={dateRange.from}
dateTo={dateRange.to} dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0} offsetYr={offsetYr}
offsetMo={segType === 'baseline' ? offsetMo : 0} offsetMo={offsetMo}
type={segType} type={segType}
/> />
</div> </div>