From af528455231059204a53ba8a0b656d174a07c206 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 25 Apr 2026 16:45:03 -0400 Subject: [PATCH] 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 --- lib/sql_generator.js | 4 +- routes/operations.js | 21 ++-- ui/src/components/Timeline.jsx | 193 ++++++++++++++++-------------- ui/src/views/Baseline.jsx | 211 ++++++++++++++++++++------------- 4 files changed, 242 insertions(+), 187 deletions(-) diff --git a/lib/sql_generator.js b/lib/sql_generator.js index bdb79db..242b08c 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -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() { diff --git a/routes/operations.js b/routes/operations.js index beddf19..7bb11d0 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -133,26 +133,23 @@ 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; const sql = applyTokens(ctx.sql, { - fc_table: ctx.table, - 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) + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + 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 }); diff --git a/ui/src/components/Timeline.jsx b/ui/src/components/Timeline.jsx index ec7e472..3218e27 100644 --- a/ui/src/components/Timeline.jsx +++ b/ui/src/components/Timeline.jsx @@ -32,116 +32,131 @@ 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 - const dpr = window.devicePixelRatio || 1 - const W = canvas.offsetWidth || 500 - canvas.width = W * dpr - canvas.height = 90 * dpr - const ctx = canvas.getContext('2d') - ctx.scale(dpr, dpr) + let raf + const draw = () => { + const dpr = window.devicePixelRatio || 1 + const W = canvas.offsetWidth || 500 + canvas.width = W * 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 PAD = { l: 8, r: 8 } + const trackH = 22 + 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 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 projStart = addMonths(srcStart, offsetMoTotal) + const projEnd = addMonths(srcEnd, offsetMoTotal) - const winStart = addMonths(srcStart, -1) - const winEnd = addMonths(projEnd, 1) - const winMs = winEnd - winStart + const winStart = addMonths(srcStart, -1) + const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1) + const winMs = winEnd - winStart - function xOf(date) { - return PAD.l + ((date - winStart) / winMs) * drawW - } + 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.stroke() - - // month ticks + year labels - const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1) - 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' + // axis + ctx.strokeStyle = '#e5e7eb' ctx.lineWidth = 1 ctx.beginPath() - ctx.moveTo(x, srcY - 8) - ctx.lineTo(x, projY + trackH) + ctx.moveTo(PAD.l, bandY - 8) + ctx.lineTo(PAD.l + drawW, bandY - 8) 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) + + // 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, 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, bandY - 10) + } } - } - // source band - const sx1 = xOf(srcStart), sx2 = xOf(srcEnd) - 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' - ctx.font = '10px system-ui' - ctx.textAlign = 'left' - ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14) + // 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, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true) - if (offsetMoTotal > 0) { - const px1 = xOf(projStart), px2 = xOf(projEnd) - ctx.fillStyle = '#dcfce7' - ctx.strokeStyle = '#86efac' - roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true) - ctx.fillStyle = '#15803d' + ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8' ctx.font = '10px system-ui' ctx.textAlign = 'left' - ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14) + const bandLabel = type === 'reference' ? 'Reference' : 'Source' + ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14) - // arrow - const arrowY = srcY + trackH / 2 - ctx.strokeStyle = '#94a3b8' - ctx.lineWidth = 1 - ctx.setLineDash([3, 3]) - ctx.beginPath() - ctx.moveTo(sx1, arrowY) - ctx.lineTo(px1 - 2, arrowY) - ctx.stroke() - ctx.setLineDash([]) - ctx.fillStyle = '#94a3b8' - ctx.beginPath() - ctx.moveTo(px1 + 4, arrowY) - ctx.lineTo(px1 - 4, arrowY - 4) - ctx.lineTo(px1 - 4, arrowY + 4) - ctx.closePath() - ctx.fill() - const label = '+' + (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) + // projected band + arrow (baseline only, when offset > 0) + if (twoBands) { + const px1 = xOf(projStart), px2 = xOf(projEnd) + ctx.fillStyle = '#dcfce7' + ctx.strokeStyle = '#86efac' + roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true) + ctx.fillStyle = '#15803d' + ctx.font = '10px system-ui' + ctx.textAlign = 'left' + ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14) + + const arrowY = bandY + trackH / 2 + ctx.strokeStyle = '#94a3b8' + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.beginPath() + ctx.moveTo(sx1, arrowY) + ctx.lineTo(px1 - 2, arrowY) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = '#94a3b8' + ctx.beginPath() + ctx.moveTo(px1 + 4, arrowY) + ctx.lineTo(px1 - 4, arrowY - 4) + ctx.lineTo(px1 - 4, arrowY + 4) + ctx.closePath() + ctx.fill() + const offsetLabel = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '') + ctx.fillStyle = '#64748b' + ctx.font = '9px system-ui' + ctx.textAlign = 'center' + 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 + return } diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 2b1a8bf..e62f762 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -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() { + @@ -346,19 +344,60 @@ export default function Baseline() { {log.length === 0 && ( - + )} - {log.map((entry, i) => ( - - - - - - - - ))} + {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 ( + <> + setExpandedId(isOpen ? null : entry.id)} + className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`} + > + + + + + + + + {isOpen && ( + + + + )} + + ) + })}
# note by
No segments loaded yet
No segments loaded yet
{log.length - i}{entry.note || }{entry.pf_user}{new Date(entry.stamp).toLocaleDateString()} - -
+ {isOpen ? '▾' : '▸'} + {log.length - i} + + {entry.operation} + + {entry.note || } + {entry.pf_user}{new Date(entry.stamp).toLocaleDateString()} + +
+
+
+ WHERE + {params.where_clause || '—'} +
+
+ offset + {params.date_offset || '0 days'} +
+ {dr && ( +
+ +
+ )} +
+
@@ -370,6 +409,23 @@ export default function Baseline() {
+ {/* Type toggle */} +
+ +
+ {['baseline', 'reference'].map(t => ( + + ))} +
+ {segType === 'reference' && ( + dates land verbatim — no offset applied + )} +
+ {/* Description */}
@@ -383,7 +439,9 @@ export default function Baseline() {
- {filters.map((f, i) => ( + {filters.map((f, i) => { + const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date' + return (
{f.op === 'BETWEEN' && <> - 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" /> + 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" /> and - 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" /> + 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 === '!=') && ( - 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" /> + 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') && ( 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" /> )}
- ))} + ) + })} {filters.length === 0 && ( No filters — at least one is required )}
- {/* Date offset */} -
- -
- setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> - yr - setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> - mo + {/* Date offset — baseline only */} + {segType === 'baseline' && ( +
+ +
+ setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> + yr + setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> + mo +
-
+ )} {/* Timeline */} {dateRange && (
-
- +
+
)} @@ -438,37 +505,13 @@ export default function Baseline() { setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
- {/* Reference */} -
-
- Reference optional — prior-period rows for comparison -
-
-
- -
- setRefFrom(e.target.value)} placeholder="2024-01-01" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" /> - to - setRefTo(e.target.value)} placeholder="2024-12-31" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" /> -
-
-
- - setRefNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" /> -
- -
-
- }