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() {
|
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 *
|
||||||
|
|||||||
@ -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
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
|
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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user