From 682968b820c8db2681c61c682ace84d1d4899e4f Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sun, 3 May 2026 22:06:17 -0400 Subject: [PATCH] Fix slice handling: payload preview, date column support, subtotal filter - Add PayloadPreview component showing the exact JSON that will be POSTed, live-updating as form fields change (value_incr shown as computed delta) - buildEffectiveSlice strips expression/system columns and converts Perspective ms-timestamps to ISO date strings for date-role columns - fetchCurrentTotals now includes date columns in Perspective view filter (passing ms number as Perspective expects) so subtotals respect the clicked date - Server buildWhere now receives filterCols (dimensions + date cols) so date values reach the SQL WHERE clause correctly Co-Authored-By: Claude Sonnet 4.6 --- routes/operations.js | 9 ++++-- ui/src/views/Forecast.jsx | 66 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/routes/operations.js b/routes/operations.js index ca4cb6e..ca44ab9 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -31,6 +31,7 @@ module.exports = function(pool) { ); const colMeta = colResult.rows; const dimCols = colMeta.filter(c => c.role === 'dimension').map(c => c.cname); + const dateCols = colMeta.filter(c => c.role === 'date').map(c => c.cname); const valueCol = colMeta.find(c => c.role === 'value')?.cname; const unitsCol = colMeta.find(c => c.role === 'units')?.cname; @@ -48,6 +49,8 @@ module.exports = function(pool) { table: fcTable(version.tname, version.id), colMeta, dimCols, + dateCols, + filterCols: [...dimCols, ...dateCols], valueCol, unitsCol, sql: sqlResult.rows[0].sql @@ -303,7 +306,7 @@ module.exports = function(pool) { const ctx = await getContext(parseInt(req.params.id), 'scale'); if (!guardOpen(ctx.version, res)) return; - const whereClause = buildWhere(slice, ctx.dimCols); + const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); let absValueIncr = value_incr || 0; @@ -361,7 +364,7 @@ module.exports = function(pool) { const ctx = await getContext(parseInt(req.params.id), 'recode'); if (!guardOpen(ctx.version, res)) return; - const whereClause = buildWhere(slice, ctx.dimCols); + const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const setClause = buildSetClause(ctx.dimCols, set); @@ -398,7 +401,7 @@ module.exports = function(pool) { if (!guardOpen(ctx.version, res)) return; const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; - const whereClause = buildWhere(slice, ctx.dimCols); + const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const setClause = buildSetClause(ctx.dimCols, set); diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 3fffda6..93192ca 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -113,11 +113,15 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname if (!valueCol && !unitsCol) return try { - const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname)) + const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname)) + const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname)) const filters = [ ...Object.entries(sliceObj) .filter(([col]) => dimNames.has(col)) .map(([col, val]) => [col, '==', val]), + ...Object.entries(sliceObj) + .filter(([col]) => dateNames.has(col)) + .map(([col, val]) => [col, '==', Number(val)]), ['pf_iter', '!=', 'reference'], ] const view = await tableRef.current.view({ filter: filters }) @@ -385,7 +389,9 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou async function submitOp(op) { if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return } - let body = { pf_user: 'admin', slice } + const effectiveSlice = buildEffectiveSlice(slice) + if (!Object.keys(effectiveSlice).length) { flash('No dimension or date columns in slice — check col_meta', 'error'); return } + let body = { pf_user: 'admin', slice: effectiveSlice } if (op === 'scale') { let vi = null, ui = null @@ -430,6 +436,50 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou } catch (err) { flash(err.message, 'error') } } + function buildEffectiveSlice(raw) { + const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname)) + const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname)) + const out = {} + for (const [k, v] of Object.entries(raw)) { + if (dimCols.has(k)) { out[k] = v; continue } + if (dateCols.has(k)) { + const ms = Number(v) + out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v + } + } + return out + } + + function buildPayload(op) { + if (!Object.keys(slice).length) return null + const effectiveSlice = buildEffectiveSlice(slice) + let body = { pf_user: 'admin', slice: effectiveSlice } + if (op === 'scale') { + let vi = null, ui = null + if (scaleMode === 'target') { + const curValue = currentTotals?.total?.value + const curUnits = currentTotals?.total?.units + if (scalePrice !== '' && curUnits != null && curValue != null) + vi = (parseFloat(scalePrice) * curUnits) - curValue + if (scaleValue !== '' && curValue != null) + vi = parseFloat(scaleValue) - curValue + if (scaleUnits !== '' && curUnits != null) + ui = parseFloat(scaleUnits) - curUnits + } else { + if (scaleValue !== '') vi = parseFloat(scaleValue) + if (scaleUnits !== '') ui = parseFloat(scaleUnits) + } + body = { ...body, note: scaleNote || undefined, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct } + } else if (op === 'recode') { + const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim())) + body = { ...body, note: recodeNote || undefined, set } + } else if (op === 'clone') { + const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim())) + body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 } + } + return body + } + function flash(text, type = 'ok') { setMsg({ text, type }) setTimeout(() => setMsg(null), 3000) @@ -785,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou )} setScaleNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('scale')}>Apply Scale } @@ -797,6 +848,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou ))} setRecodeNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('recode')}>Apply Recode } @@ -810,6 +862,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou ))} setCloneScale(e.target.value)} className={inp} /> setCloneNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('clone')}>Apply Clone } @@ -868,3 +921,12 @@ function Submit({ onClick, children }) { ) } + +function PayloadPreview({ payload }) { + if (!payload) return null + return ( +
+      {JSON.stringify(payload, null, 2)}
+    
+ ) +}