Unify baseline/reference into one form; fix timeline for both types

- Baseline.jsx: merge Reference section into Add Segment form with baseline/reference toggle; segment rows now clickable to expand stored WHERE clause + timeline; date filter inputs use type="date" for date-role columns
- Timeline.jsx: add type prop ('baseline'|'reference'); reference band uses purple; single-band height shrinks to 52px; canvas uses requestAnimationFrame to fix offsetWidth=0 on mount
- operations.js: reference route now accepts where_clause like baseline (drops date_from/date_to)
- sql_generator.js: reference SQL template uses {{filter_clause}} instead of hardcoded BETWEEN

Note: existing sources need Generate SQL re-run to pick up the new reference template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-25 16:45:03 -04:00
parent dc090fe394
commit af52845523
4 changed files with 242 additions and 187 deletions

View File

@ -80,10 +80,10 @@ ilog AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
WHERE {{filter_clause}}
RETURNING *
)
SELECT * FROM ins`.trim();
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildScale() {

View File

@ -133,10 +133,8 @@ 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 { date_from, date_to, pf_user, note } = req.body;
if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' });
}
const { where_clause, pf_user, note } = req.body;
const filterClause = (where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
@ -146,13 +144,12 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ date_from, date_to })),
date_from: esc(date_from),
date_to: esc(date_to)
params: esc(JSON.stringify({ where_clause: filterClause })),
filter_clause: filterClause
});
const result = await runSQL(sql);
res.json({ rows: result.rows, rows_affected: result.rows.length });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });

View File

@ -32,83 +32,96 @@ function roundRect(ctx, x, y, w, h, r, fill, stroke) {
if (stroke) ctx.stroke()
}
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo, type = 'baseline' }) {
const canvasRef = useRef(null)
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
const twoBands = type === 'baseline' && offsetMoTotal > 0
const canvasH = twoBands ? 90 : 52
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let raf
const draw = () => {
const dpr = window.devicePixelRatio || 1
const W = canvas.offsetWidth || 500
canvas.width = W * dpr
canvas.height = 90 * dpr
canvas.height = canvasH * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
const H = 90
const PAD = { l: 8, r: 8 }
const trackH = 22
const srcY = 20
const projY = srcY + trackH + 10
const drawW = W - PAD.l - PAD.r
const bandY = twoBands ? 20 : (canvasH - trackH) / 2
const projY = bandY + trackH + 10
const srcStart = parseDate(dateFrom)
const srcEnd = parseDate(dateTo)
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
const projStart = addMonths(srcStart, offsetMoTotal)
const projEnd = addMonths(srcEnd, offsetMoTotal)
const winStart = addMonths(srcStart, -1)
const winEnd = addMonths(projEnd, 1)
const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1)
const winMs = winEnd - winStart
function xOf(date) {
return PAD.l + ((date - winStart) / winMs) * drawW
}
ctx.clearRect(0, 0, W, H)
ctx.clearRect(0, 0, W, canvasH)
// axis
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(PAD.l, srcY - 8)
ctx.lineTo(PAD.l + drawW, srcY - 8)
ctx.moveTo(PAD.l, bandY - 8)
ctx.lineTo(PAD.l + drawW, bandY - 8)
ctx.stroke()
// month ticks + year labels
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
const tickBottom = twoBands ? projY + trackH : bandY + trackH
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
const x = xOf(d)
if (x < PAD.l || x > PAD.l + drawW) continue
ctx.strokeStyle = '#f3f4f6'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, srcY - 8)
ctx.lineTo(x, projY + trackH)
ctx.moveTo(x, bandY - 8)
ctx.lineTo(x, tickBottom)
ctx.stroke()
if (d.getMonth() === 0) {
ctx.fillStyle = '#6b7280'
ctx.font = 'bold 9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(d.getFullYear(), x, srcY - 10)
ctx.fillText(d.getFullYear(), x, bandY - 10)
}
}
// source band
// first band
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
if (type === 'reference') {
ctx.fillStyle = '#f3e8ff'
ctx.strokeStyle = '#d8b4fe'
} else {
ctx.fillStyle = '#dbeafe'
ctx.strokeStyle = '#93c5fd'
}
ctx.lineWidth = 1
roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
ctx.fillStyle = '#1d4ed8'
roundRect(ctx, sx1, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8'
ctx.font = '10px system-ui'
ctx.textAlign = 'left'
ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14)
const bandLabel = type === 'reference' ? 'Reference' : 'Source'
ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14)
if (offsetMoTotal > 0) {
// projected band + arrow (baseline only, when offset > 0)
if (twoBands) {
const px1 = xOf(projStart), px2 = xOf(projEnd)
ctx.fillStyle = '#dcfce7'
ctx.strokeStyle = '#86efac'
@ -118,8 +131,7 @@ export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
ctx.textAlign = 'left'
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
// arrow
const arrowY = srcY + trackH / 2
const arrowY = bandY + trackH / 2
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
@ -135,13 +147,16 @@ export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
ctx.lineTo(px1 - 4, arrowY + 4)
ctx.closePath()
ctx.fill()
const label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
const offsetLabel = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
ctx.fillStyle = '#64748b'
ctx.font = '9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5)
ctx.fillText(offsetLabel.trim(), (sx1 + px1) / 2, arrowY - 5)
}
}, [dateFrom, dateTo, offsetYr, offsetMo])
}
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}, [dateFrom, dateTo, offsetYr, offsetMo, type, twoBands, canvasH])
return <canvas ref={canvasRef} height={90} style={{ width: '100%', display: 'block' }} />
return <canvas ref={canvasRef} height={canvasH} style={{ width: '100%', display: 'block' }} />
}

View File

@ -35,6 +35,22 @@ function getDateRange(filters) {
return null
}
function parseDateRangeFromClause(clause) {
if (!clause) return null
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
if (m) return { from: m[1], to: m[2] }
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
if (m2) return { from: m2[1], to: m2[2] }
return null
}
function parseOffset(offsetStr) {
if (!offsetStr || offsetStr === '0 days') return { yr: 0, mo: 0 }
const yr = parseInt(offsetStr.match(/(\d+)\s+year/)?.[1] || 0)
const mo = parseInt(offsetStr.match(/(\d+)\s+month/)?.[1] || 0)
return { yr, mo }
}
function emptyFilter(cols) {
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
}
@ -54,6 +70,7 @@ export default function Baseline() {
const [creatingVer, setCreatingVer] = useState(false)
// add segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([])
const [offsetYr, setOffsetYr] = useState(0)
@ -61,12 +78,7 @@ export default function Baseline() {
const [segNote, setSegNote] = useState('')
const [submitting, setSubmitting] = useState(false)
// reference form
const [refFrom, setRefFrom] = useState('')
const [refTo, setRefTo] = useState('')
const [refNote, setRefNote] = useState('')
const [loadingRef, setLoadingRef] = useState(false)
const [expandedId, setExpandedId] = useState(null)
const [msg, setMsg] = useState(null)
useEffect(() => {
@ -97,7 +109,7 @@ export default function Baseline() {
function loadLog() {
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
setLog(data.filter(e => e.operation === 'baseline'))
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
})
}
@ -160,17 +172,22 @@ export default function Baseline() {
async function loadSegment() {
const clause = buildFilterClause(filters)
if (!clause) { flash('Add at least one filter', 'error'); return }
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const isRef = segType === 'reference'
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const endpoint = isRef ? 'reference' : 'baseline'
const body = isRef
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
setSubmitting(true)
try {
const res = await fetch(`/api/versions/${versionId}/baseline`, {
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote })
body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(`Loaded ${data.row_count ?? ''} rows`)
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
@ -196,26 +213,6 @@ export default function Baseline() {
flash(`Cleared ${data.rows_deleted} rows`)
}
async function loadReference() {
if (!refFrom || !refTo) { flash('Enter a date range', 'error'); return }
setLoadingRef(true)
try {
const res = await fetch(`/api/versions/${versionId}/reference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date_from: refFrom, date_to: refTo, pf_user: 'admin', note: refNote })
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(`Loaded ${data.row_count ?? ''} reference rows`)
setRefFrom(''); setRefTo(''); setRefNote('')
} catch (err) {
flash(err.message, 'error')
} finally {
setLoadingRef(false)
}
}
async function closeVersion() {
const res = await fetch(`/api/versions/${versionId}/close`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -337,6 +334,7 @@ export default function Baseline() {
<table className="w-full text-xs">
<thead className="bg-gray-50">
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium">by</th>
@ -346,19 +344,60 @@ export default function Baseline() {
</thead>
<tbody>
{log.length === 0 && (
<tr><td colSpan={5} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{log.map((entry, i) => (
<tr key={entry.id} className="border-t border-gray-50">
{log.map((entry, i) => {
const isOpen = expandedId === entry.id
const params = entry.params || {}
const dr = parseDateRangeFromClause(params.where_clause)
const off = parseOffset(params.date_offset)
return (
<>
<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' : ''}`}
>
<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">{entry.note || <span className="text-gray-300"></span>}</td>
<td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
{entry.operation}
</span>
{entry.note || <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => undoSegment(entry.id)} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
</td>
</tr>
))}
{isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={6} className="px-4 py-3">
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
</div>
{dr && (
<div className="mt-1">
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
@ -370,6 +409,23 @@ export default function Baseline() {
</div>
<div className="p-4 flex flex-col gap-4">
{/* Type toggle */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{['baseline', 'reference'].map(t => (
<button
key={t}
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
@ -383,7 +439,9 @@ export default function Baseline() {
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
</div>
<div className="flex flex-col gap-1.5 ml-28">
{filters.map((f, i) => (
{filters.map((f, i) => {
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
return (
<div key={i} className="flex items-center gap-2 flex-wrap">
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
@ -392,26 +450,28 @@ export default function Baseline() {
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
{f.op === 'BETWEEN' && <>
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono bg-white" />
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
<span className="text-gray-400 text-xs">and</span>
<input value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono bg-white" />
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
</>}
{(f.op === '=' || f.op === '!=') && (
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
)}
{(f.op === 'IN' || f.op === 'NOT IN') && (
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
)}
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
))}
)
})}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
</div>
{/* Date offset */}
{/* Date offset — baseline only */}
{segType === 'baseline' && (
<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">
@ -421,12 +481,19 @@ export default function Baseline() {
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
<div className="ml-28">
<div className="bg-white border border-gray-200 rounded p-3">
<Timeline dateFrom={dateRange.from} dateTo={dateRange.to} offsetYr={offsetYr} offsetMo={offsetMo} />
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
type={segType}
/>
</div>
</div>
)}
@ -438,37 +505,13 @@ export default function Baseline() {
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
</div>
<button onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{submitting ? 'Loading…' : 'Load Segment'}
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
</button>
</div>
</div>
</div>
{/* Reference */}
<div 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">
Reference <span className="text-gray-300 font-normal normal-case">optional prior-period rows for comparison</span>
</div>
<div className="p-4 flex items-end gap-3 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Date range</label>
<div className="flex items-center gap-2">
<input value={refFrom} onChange={e => setRefFrom(e.target.value)} placeholder="2024-01-01" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
<span className="text-xs text-gray-400">to</span>
<input value={refTo} onChange={e => setRefTo(e.target.value)} placeholder="2024-12-31" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
</div>
</div>
<div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label>
<input value={refNote} onChange={e => setRefNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
</div>
<button onClick={loadReference} disabled={loadingRef} className="border border-gray-200 text-gray-600 text-xs px-4 py-1.5 rounded hover:bg-gray-50 disabled:opacity-50 shrink-0">
{loadingRef ? 'Loading…' : 'Load Reference'}
</button>
</div>
</div>
</>}
</div>