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:
parent
4fde752b54
commit
953ae2709f
@ -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 *
|
||||
|
||||
@ -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
11
todo.md
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user