From a89bd36f404d5c3353ee782cbf2c96204d6a562d Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sun, 19 Apr 2026 15:36:34 -0400 Subject: [PATCH] Stacks: calibrate modal redesign, layout column cleanup, SQL preview sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calibrate modal now auto-fetches computed sum and shows live reconciliation table (data sum, known balance, plug) without requiring a button click - as_of_date is now optional in calibrate — omitting it sums all transactions - SQL preview syncs current UI state to DB before fetching so preview is always accurate - Pivot cleanLayout strips stale columns from saved layouts when switching stack views Co-Authored-By: Claude Sonnet 4.6 --- api/routes/stacks.js | 7 +- database/queries/stacks.sql | 15 +++- ui/src/pages/Pivot.jsx | 52 ++++++++++-- ui/src/pages/Stacks.jsx | 159 +++++++++++++++++++++++------------- 4 files changed, 161 insertions(+), 72 deletions(-) diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 3f0a1aa..4f38cc2 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -149,11 +149,12 @@ module.exports = (pool) => { router.post('/:name/calibrate', async (req, res, next) => { try { const { as_of_date, known_balance, source_name } = req.body; - if (!as_of_date || known_balance === undefined) { - return res.status(400).json({ error: 'as_of_date and known_balance are required' }); + if (known_balance === undefined) { + return res.status(400).json({ error: 'known_balance is required' }); } + const dateExpr = as_of_date ? `${lit(as_of_date)}::date` : 'NULL'; const result = await pool.query( - `SELECT calibrate_balance(${lit(req.params.name)}, ${source_name ? lit(source_name) : 'NULL'}, ${lit(as_of_date)}::date, ${lit(known_balance)}::numeric) AS result` + `SELECT calibrate_balance(${lit(req.params.name)}, ${source_name ? lit(source_name) : 'NULL'}, ${dateExpr}, ${lit(known_balance)}::numeric) AS result` ); res.json(result.rows[0].result); } catch (err) { next(err); } diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index b7519a2..3382a69 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -214,10 +214,17 @@ BEGIN END IF; BEGIN - v_sql := format( - 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date', - v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date - ); + IF p_as_of_date IS NULL THEN + v_sql := format( + 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I', + v_src.amount_field, v_src.amount_sign, p_source_name + ); + ELSE + v_sql := format( + 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date', + v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date + ); + END IF; EXECUTE v_sql INTO v_running; EXCEPTION WHEN undefined_table THEN RETURN json_build_object('success', false, 'error', 'Source view not found — generate the source view first'); diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 275a143..bb5c1b1 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -168,6 +168,18 @@ export default function Pivot({ source }) { tableRef.current = table const viewer = viewerRef.current + const validCols = new Set(Object.keys(rows[0] || {})) + + function cleanLayout(cfg) { + if (!cfg) return cfg + const clean = { ...cfg } + if (clean.columns) clean.columns = clean.columns.filter(c => c == null || validCols.has(c)) + if (clean.group_by) clean.group_by = clean.group_by.filter(c => validCols.has(c)) + if (clean.split_by) clean.split_by = clean.split_by.filter(c => validCols.has(c)) + if (clean.sort) clean.sort = clean.sort.filter(([c]) => validCols.has(c)) + if (clean.filter) clean.filter = clean.filter.filter(([c]) => validCols.has(c)) + return clean + } viewer.addEventListener('perspective-click', async (e) => { const detail = e.detail || {} @@ -208,7 +220,7 @@ export default function Pivot({ source }) { const plugin = await viewer.getPlugin() const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView)) if (savedLayout) { - const parsed = JSON.parse(savedLayout) + const parsed = cleanLayout(JSON.parse(savedLayout)) await viewer.restore(parsed) await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG) if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth) @@ -240,14 +252,38 @@ export default function Pivot({ source }) { async function applyLayout(layout) { const viewer = viewerRef.current if (!viewer) return - await viewer.restore(layout.config) - if (layout.config.plugin_config) { - const plugin = await viewer.getPlugin() - await plugin.restore(layout.config.plugin_config) + try { + const validCols = new Set(Object.keys(allRowsRef.current[0] || {})) + function cleanLayout(cfg) { + if (!cfg) return cfg + const clean = { ...cfg } + if (clean.columns) clean.columns = clean.columns.filter(c => c == null || validCols.has(c)) + if (clean.group_by) clean.group_by = clean.group_by.filter(c => validCols.has(c)) + if (clean.split_by) clean.split_by = clean.split_by.filter(c => validCols.has(c)) + if (clean.sort) clean.sort = clean.sort.filter(([c]) => validCols.has(c)) + if (clean.filter) clean.filter = clean.filter.filter(([c]) => validCols.has(c)) + return clean + } + const cleaned = cleanLayout(layout.config) + await viewer.restore(cleaned) + if (cleaned.plugin_config) { + const plugin = await viewer.getPlugin() + await plugin.restore(cleaned.plugin_config) + } + await applyExpandDepth(viewer, cleaned.expand_depth ?? null) + setActiveLayoutId(layout.id) + localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned)) + } catch { + // Layout references columns that no longer exist — remove it + if (viewType === 'stack') { + const updated = layouts.filter(l => l.id !== layout.id) + setLayouts(updated) + localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated)) + } + localStorage.removeItem(LAYOUT_KEY(selectedView)) + setActiveLayoutId(null) + await viewer.restore({ table: selectedView, settings: false }) } - await applyExpandDepth(viewer, layout.config.expand_depth ?? null) - setActiveLayoutId(layout.id) - localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config)) } async function captureConfig() { diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index fd6da27..89a19db 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -14,72 +14,99 @@ const FIELD_TYPES = ['text', 'numeric', 'date'] // ── Calibrate modal ──────────────────────────────────────────────────────────── -function CalibrateModal({ stack, sourceName, onClose, onApply }) { +function fmt(n) { + return Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +function CalibrateModal({ stack, sourceName, currentOffset, onClose, onApply }) { const [asOf, setAsOf] = useState('') const [known, setKnown] = useState('') - const [result, setResult] = useState(null) - const [applyOffset, setApplyOffset] = useState('') + const [computed, setComputed] = useState(null) // raw sum from DB (no offset) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [applyOffset, setApplyOffset] = useState('') + const debounceRef = useRef(null) - async function handleCalc() { - setError(''); setResult(null); setLoading(true) - try { - const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) }) - setResult(r) - if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2)) - } catch (e) { setError(e.message) } - finally { setLoading(false) } - } + const knownNum = parseFloat(known) + const hasKnown = known !== '' && !isNaN(knownNum) + const plug = hasKnown && computed !== null ? knownNum - computed : null + + // Auto-fetch computed sum on mount (all transactions) and whenever date changes + useEffect(() => { + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(async () => { + setLoading(true); setError('') + try { + const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf || null, known_balance: 0 }) + if (r.success) setComputed(Number(r.computed_sum)) + else setError(r.error) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + }, asOf ? 400 : 0) + return () => clearTimeout(debounceRef.current) + }, [asOf]) + + // Keep applyOffset in sync with plug + useEffect(() => { + if (plug !== null) setApplyOffset(plug.toFixed(2)) + }, [plug]) return (
{ if (e.target === e.currentTarget) onClose() }}> -
e.stopPropagation()}> +
e.stopPropagation()}>
Calibrate — {sourceName}
-

- Enter a known good balance at a specific date. The system will compute the offset needed to make the running balance match. -

-
-
- - setAsOf(e.target.value)} /> + + {/* Date */} +
+ + setAsOf(e.target.value)} /> +
+ + {/* Reconciliation table */} +
+
+ Data sum at date + + {loading ? : computed !== null ? fmt(computed) : } +
-
- - setKnown(e.target.value)} placeholder="e.g. 12450.22" /> +
+ Known balance + setKnown(e.target.value)} + />
-
+ + {error &&

{error}

} + + {/* Apply */} +
+ setApplyOffset(e.target.value)} /> + - {error &&

{error}

} - {result && !result.success &&

{result.error}

} - {result && result.success && ( -
-
Computed sum at date{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}
-
Known balance{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
-
Suggested offset{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}
-
- )} - {result && result.success && ( -
-
- - setApplyOffset(e.target.value)} /> -
- -
- )}
@@ -135,15 +162,31 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql }) }, [members.map(m => m.source_name).join(',')]) - // Live SQL preview — debounced whenever fields, sources, or mappings change + // Live SQL preview — debounced; syncs current UI state to DB first so preview is accurate const previewTimer = useRef(null) useEffect(() => { clearTimeout(previewTimer.current) - previewTimer.current = setTimeout(() => { - api.previewStackSql(stack.name) - .then(r => { if (r.success) onSqlGenerated?.(r.sql) }) - .catch(() => {}) - }, 400) + previewTimer.current = setTimeout(async () => { + try { + for (const m of members) { + const cfg = srcCfg[m.source_name] || {} + await api.upsertStackSource(stack.name, m.source_name, { + field_map: cfg.field_map || {}, + amount_sign: cfg.sign ?? 1, + balance_offset: cfg.offset ?? 0, + amount_field: cfg.amount_field || null, + date_field: cfg.date_field || null, + }) + } + await api.updateStack(stack.name, { + fields, + amount_field: amountCanonical || null, + date_field: dateCanonical || null, + }) + const r = await api.previewStackSql(stack.name) + if (r.success) onSqlGenerated?.(r.sql) + } catch {} + }, 600) return () => clearTimeout(previewTimer.current) }, [ JSON.stringify(fields), @@ -360,6 +403,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql } const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) + if (addingSrc === '' && availableSources.length === 1) setAddingSrc(availableSources[0].name) return (
@@ -589,6 +633,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql setCalibratingSource(null)} onApply={offset => applyCalibration(calibratingSource, offset)} />