diff --git a/CLAUDE.md b/CLAUDE.md index 26b7ded..49d4691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,9 +124,15 @@ records.data → apply_transformations() → ### Deduplication - `constraint_key` is a JSONB object of the constraint field values (readable, no hashing) -- Dedup is enforced at import time via CTE — no unique DB constraint -- Intra-file duplicate rows are allowed (bank may send identical rows); they all insert -- On re-import, all rows whose constraint_key already exists in the DB are skipped +- Dedup is enforced at import time via CTE — NO unique DB constraint on constraint_key +- **The constraint key is for cross-batch re-import protection, NOT record uniqueness** +- Within a single import batch, ALL rows insert regardless of duplicate constraint keys + - Banks legitimately send multiple identical-looking transactions (same date, description, amount) + - Example: 11 Cedar Point merchandise charges on one day — all should insert in one batch +- On re-import of overlapping date range, rows whose constraint_key already exists in DB are skipped + - This prevents double-counting when you re-run a month-to-date export the next day +- NEVER use `ON CONFLICT (constraint_key)` — there is no unique constraint and it would wrongly + drop legitimate duplicate transactions from the same batch - Deleting an import log entry cascades to all records from that batch (import_id FK) ### Error Handling diff --git a/api/routes/sources.js b/api/routes/sources.js index 271ad2c..c911396 100644 --- a/api/routes/sources.js +++ b/api/routes/sources.js @@ -187,7 +187,11 @@ module.exports = (pool) => { router.post('/:name/view', async (req, res, next) => { try { const result = await pool.query(`SELECT generate_source_view(${lit(req.params.name)}) as result`); - res.json(result.rows[0].result); + const data = result.rows[0].result; + if (data && data.success) { + await pool.query(`UPDATE dataflow.sources SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`); + } + res.json(data); } catch (err) { next(err); } diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 75f1b66..7d88271 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -102,7 +102,11 @@ module.exports = (pool) => { router.post('/:name/view', async (req, res, next) => { try { const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`); - res.json(result.rows[0].result); + const data = result.rows[0].result; + if (data && data.success) { + await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`); + } + res.json(data); } catch (err) { next(err); } }); diff --git a/api/routes/status.js b/api/routes/status.js new file mode 100644 index 0000000..ef16233 --- /dev/null +++ b/api/routes/status.js @@ -0,0 +1,14 @@ +const express = require('express'); + +module.exports = (pool) => { + const router = express.Router(); + + router.get('/', async (req, res, next) => { + try { + const result = await pool.query('SELECT get_status() AS result'); + res.json(result.rows[0].result); + } catch (err) { next(err); } + }); + + return router; +}; diff --git a/api/server.js b/api/server.js index 60a7982..b1e6532 100644 --- a/api/server.js +++ b/api/server.js @@ -55,6 +55,7 @@ const rulesRoutes = require('./routes/rules'); const mappingsRoutes = require('./routes/mappings'); const recordsRoutes = require('./routes/records'); const stacksRoutes = require('./routes/stacks'); +const statusRoutes = require('./routes/status'); // Mount routes app.use('/api/sources', sourcesRoutes(pool)); @@ -62,6 +63,7 @@ app.use('/api/rules', rulesRoutes(pool)); app.use('/api/mappings', mappingsRoutes(pool)); app.use('/api/records', recordsRoutes(pool)); app.use('/api/stacks', stacksRoutes(pool)); +app.use('/api/status', statusRoutes(pool)); // Health check app.get('/health', (req, res) => { diff --git a/database/functions.sql b/database/functions.sql index 3301f7a..6d01b09 100644 --- a/database/functions.sql +++ b/database/functions.sql @@ -506,10 +506,10 @@ BEGIN v_view := 'dfv.' || quote_ident(p_source_name); - EXECUTE format('DROP VIEW IF EXISTS %s', v_view); + EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view); v_sql := format( - 'CREATE VIEW %s AS SELECT %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL', + 'CREATE VIEW %s AS SELECT id, %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL', v_view, v_cols, p_source_name ); diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index 8185319..6139ad6 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -252,10 +252,12 @@ DECLARE v_src_field TEXT; v_amt_src TEXT; v_date_src TEXT; - v_view TEXT; - v_sql TEXT; - v_has_bal BOOLEAN; - v_canon_cols TEXT; + v_view TEXT; + v_sql TEXT; + v_has_bal BOOLEAN; + v_canon_cols TEXT; + v_src_bal_cols TEXT; + v_total_offset NUMERIC := 0; BEGIN SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; IF NOT FOUND THEN @@ -304,20 +306,21 @@ BEGIN END IF; END LOOP; - -- Per-source running balance with calibration offset baked in - IF v_has_bal AND v_src.amount_field IS NOT NULL AND v_src.date_field IS NOT NULL THEN - v_select := v_select || format( - ', SUM(%I * %s) OVER (ORDER BY %I ASC, id ASC) + %s AS source_balance', - v_src.amount_field, v_src.amount_sign, v_src.date_field, v_src.balance_offset - ); - ELSE - v_select := v_select || ', NULL::numeric AS source_balance'; - END IF; - v_select := v_select || format(' FROM dfv.%I', v_src.source_name); v_ctes := v_ctes || format('%I AS (%s)', v_src.source_name, v_select); v_cte_names := v_cte_names || quote_ident(v_src.source_name); + + -- Accumulate carried-forward source balance column and total offset + IF v_has_bal THEN + IF v_src_bal_cols IS NOT NULL THEN v_src_bal_cols := v_src_bal_cols || ', '; END IF; + v_src_bal_cols := COALESCE(v_src_bal_cols, '') || format( + 'SUM(CASE WHEN _source = %L THEN %I END) OVER (ORDER BY %I ASC, _id ASC) + %s AS %I', + v_src.source_name, v_stack.amount_field, v_stack.date_field, + v_src.balance_offset, v_src.source_name || '_balance' + ); + v_total_offset := v_total_offset + v_src.balance_offset; + END IF; END LOOP; IF array_length(v_ctes, 1) IS NULL THEN @@ -326,7 +329,7 @@ BEGIN CREATE SCHEMA IF NOT EXISTS dfv; v_view := 'dfv.' || quote_ident(p_stack_name); - EXECUTE format('DROP VIEW IF EXISTS %s', v_view); + EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view); v_canon_cols := ( SELECT string_agg(quote_ident(f->>'name'), ', ') @@ -334,20 +337,24 @@ BEGIN ); IF v_has_bal THEN - -- net_balance: cumulative signed amount across all sources + stack-level offset + -- net_balance = sum of all signed amounts + total of all source offsets + -- This ensures net_balance = sum of per-source carried-forward balances on every row v_sql := format( 'CREATE VIEW %s AS ' 'WITH %s, _stacked AS (SELECT * FROM %s) ' - 'SELECT _source, _id, %s, source_balance, ' - 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance ' - 'FROM _stacked', + 'SELECT _source, _id, %s, ' + 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, ' + '%s ' + 'FROM _stacked ORDER BY %I DESC, _id DESC', v_view, array_to_string(v_ctes, ', '), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '), v_canon_cols, v_stack.amount_field, v_stack.date_field, - v_stack.balance_offset + v_total_offset, + v_src_bal_cols, + v_stack.date_field ); ELSE v_sql := format( diff --git a/database/queries/status.sql b/database/queries/status.sql new file mode 100644 index 0000000..19fc8ea --- /dev/null +++ b/database/queries/status.sql @@ -0,0 +1,85 @@ +-- +-- Status tracking: view_generated_at on sources and stacks +-- Cleared by triggers when definitions change; set by API when views are generated. +-- + +SET search_path TO dataflow, public; + +-- Add view_generated_at columns +ALTER TABLE dataflow.sources ADD COLUMN IF NOT EXISTS view_generated_at TIMESTAMPTZ; +ALTER TABLE dataflow.stacks ADD COLUMN IF NOT EXISTS view_generated_at TIMESTAMPTZ; + +------------------------------------------------------ +-- Trigger: clear source view_generated_at when rules change +------------------------------------------------------ +CREATE OR REPLACE FUNCTION dataflow.rules_changed() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE dataflow.sources SET view_generated_at = NULL + WHERE name = COALESCE(NEW.source_name, OLD.source_name); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_rules_changed ON dataflow.rules; +CREATE TRIGGER trg_rules_changed +AFTER INSERT OR UPDATE OR DELETE ON dataflow.rules +FOR EACH ROW EXECUTE FUNCTION dataflow.rules_changed(); + +------------------------------------------------------ +-- Trigger: clear source view_generated_at when mappings change +------------------------------------------------------ +CREATE OR REPLACE FUNCTION dataflow.mappings_changed() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE dataflow.sources SET view_generated_at = NULL + WHERE name = COALESCE(NEW.source_name, OLD.source_name); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_mappings_changed ON dataflow.mappings; +CREATE TRIGGER trg_mappings_changed +AFTER INSERT OR UPDATE OR DELETE ON dataflow.mappings +FOR EACH ROW EXECUTE FUNCTION dataflow.mappings_changed(); + +------------------------------------------------------ +-- Trigger: clear stack view_generated_at when sources change +------------------------------------------------------ +CREATE OR REPLACE FUNCTION dataflow.stack_sources_changed() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE dataflow.stacks SET view_generated_at = NULL + WHERE name = COALESCE(NEW.stack_name, OLD.stack_name); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_stack_sources_changed ON dataflow.stack_sources; +CREATE TRIGGER trg_stack_sources_changed +AFTER INSERT OR UPDATE OR DELETE ON dataflow.stack_sources +FOR EACH ROW EXECUTE FUNCTION dataflow.stack_sources_changed(); + +------------------------------------------------------ +-- Function: get_status +-- Returns sources and stacks whose view is stale (null or never generated) +------------------------------------------------------ +CREATE OR REPLACE FUNCTION get_status() +RETURNS JSON AS $$ +DECLARE + v_sources JSON; + v_stacks JSON; +BEGIN + SELECT COALESCE(json_agg(json_build_object('name', name, 'view_generated_at', view_generated_at) ORDER BY name), '[]'::json) + INTO v_sources + FROM dataflow.sources + WHERE view_generated_at IS NULL; + + SELECT COALESCE(json_agg(json_build_object('name', name, 'view_generated_at', view_generated_at) ORDER BY name), '[]'::json) + INTO v_stacks + FROM dataflow.stacks + WHERE view_generated_at IS NULL; + + RETURN json_build_object('stale_sources', v_sources, 'stale_stacks', v_stacks); +END; +$$ LANGUAGE plpgsql STABLE; diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d3323d0..50abb9f 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -30,6 +30,10 @@ export default function App() { const [sources, setSources] = useState([]) const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') const [sidebarOpen, setSidebarOpen] = useState(false) + // Sets of names whose dfv view is out of sync with current definitions + const [staleSources, setStaleSources] = useState(new Set()) + const [staleStacks, setStaleStacks] = useState(new Set()) + const [generating, setGenerating] = useState({}) // { 'source:name': true } async function handleLogin(user, pass) { setCredentials(user, pass) @@ -50,6 +54,45 @@ export default function App() { setAuthed(false) setLoginUser('') setSources([]) + setStaleSources(new Set()) + setStaleStacks(new Set()) + } + + // Load initial stale state from DB once on login + useEffect(() => { + if (!authed) return + api.getStatus().then(s => { + setStaleSources(new Set((s.stale_sources || []).map(x => x.name))) + setStaleStacks(new Set((s.stale_stacks || []).map(x => x.name))) + }).catch(() => {}) + }, [authed]) + + function markSourceStale(name) { + setStaleSources(prev => new Set([...prev, name])) + } + function markStackStale(name) { + setStaleStacks(prev => new Set([...prev, name])) + } + function clearStackStale(name) { + setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n }) + } + + async function handleGenerateSource(name) { + setGenerating(g => ({ ...g, [`src:${name}`]: true })) + try { + await api.generateView(name) + setStaleSources(prev => { const n = new Set(prev); n.delete(name); return n }) + } catch (e) { alert(e.message) } + finally { setGenerating(g => { const n = { ...g }; delete n[`src:${name}`]; return n }) } + } + + async function handleGenerateStack(name) { + setGenerating(g => ({ ...g, [`stk:${name}`]: true })) + try { + await api.generateStackView(name) + setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n }) + } catch (e) { alert(e.message) } + finally { setGenerating(g => { const n = { ...g }; delete n[`stk:${name}`]; return n }) } } // On mount, restore session if credentials are saved @@ -141,17 +184,48 @@ export default function App() { Dataflow + {(staleSources.size > 0 || staleStacks.size > 0) && ( +
+ View out of sync: + {[...staleSources].map(name => ( + + {name} + + + ))} + {staleSources.size > 0 && staleStacks.size > 0 && |} + {[...staleStacks].map(name => ( + + stack: {name} + + + ))} +
+ )} +
} /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> - } /> + } /> } />
diff --git a/ui/src/api.js b/ui/src/api.js index 339f9ac..65e93b3 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -125,6 +125,9 @@ export const api = { getStackBalance: (name) => request('GET', `/stacks/${name}/balance`), calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }), + // Status + getStatus: () => request('GET', '/status'), + // Records getRecords: (source, limit = 100, offset = 0) => request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), diff --git a/ui/src/pages/Mappings.jsx b/ui/src/pages/Mappings.jsx index e78c253..1c5156d 100644 --- a/ui/src/pages/Mappings.jsx +++ b/ui/src/pages/Mappings.jsx @@ -104,7 +104,7 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) { ) } -export default function Mappings({ source }) { +export default function Mappings({ source, onStale }) { const [rules, setRules] = useState([]) const [selectedRule, setSelectedRule] = useState('') const [allValues, setAllValues] = useState([]) @@ -263,6 +263,7 @@ export default function Mappings({ source }) { valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output } : x )) } + onStale?.(source) setDrafts(d => { const n = { ...d }; delete n[k]; return n }) } catch (err) { alert(err.message) @@ -309,6 +310,7 @@ export default function Mappings({ source }) { setSaving(s => ({ ...s, [k]: false })) } })) + onStale?.(source) setSelected(new Set()) setBulkDraft({}) } @@ -317,6 +319,7 @@ export default function Mappings({ source }) { if (!row.mapping_id) return try { await api.deleteMapping(row.mapping_id) + onStale?.(source) setAllValues(av => av.map(x => valueKey(x.extracted_value) === valueKey(row.extracted_value) ? { ...x, is_mapped: false, mapping_id: null, output: null } diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index f12ebad..275a143 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -92,7 +92,22 @@ export default function Pivot({ source }) { const [clickDetail, setClickDetail] = useState(null) const [decimals, setDecimals] = useState(2) - // Named layouts + // View selector: source or a stack + const [stacks, setStacks] = useState([]) + const [selectedView, setSelectedView] = useState(source) // name of active dfv view + const [viewType, setViewType] = useState('source') // 'source' | 'stack' + + useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, []) + + // When sidebar source changes, reset to that source + useEffect(() => { + if (viewType === 'source') setSelectedView(source) + }, [source]) + + function selectSource() { setViewType('source'); setSelectedView(source) } + function selectStack(name) { setViewType('stack'); setSelectedView(name) } + + // Named layouts — stacks use localStorage only (no server FK to sources) const [layouts, setLayouts] = useState([]) const [activeLayoutId, setActiveLayoutId] = useState(null) const [saveAsName, setSaveAsName] = useState('') @@ -105,15 +120,21 @@ export default function Pivot({ source }) { } const loadLayouts = useCallback(async () => { - if (!source) return + if (!selectedView) return try { - const rows = await api.getPivotLayouts(source) - setLayouts(rows) + if (viewType === 'source') { + const rows = await api.getPivotLayouts(selectedView) + setLayouts(rows) + } else { + // Stacks: localStorage only + const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`) + setLayouts(stored ? JSON.parse(stored) : []) + } } catch {} - }, [source]) + }, [selectedView, viewType]) useEffect(() => { - if (!source) return + if (!selectedView) return let cancelled = false setInspectedRows(null) setClickDetail(null) @@ -129,7 +150,7 @@ export default function Pivot({ source }) { try { const [perspective, rows] = await Promise.all([ loadPerspective(), - fetchAllRows(source), + fetchAllRows(selectedView), ]) if (cancelled) return if (!rows.length) { setStatus('noview'); return } @@ -142,7 +163,7 @@ export default function Pivot({ source }) { if (cancelled) { worker.terminate(); return } workerRef.current = worker - const table = await worker.table(rows, { name: source }) + const table = await worker.table(rows, { name: selectedView }) if (cancelled) return tableRef.current = table @@ -185,14 +206,14 @@ export default function Pivot({ source }) { await viewer.load(worker) const plugin = await viewer.getPlugin() - const savedLayout = localStorage.getItem(LAYOUT_KEY(source)) + const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView)) if (savedLayout) { const parsed = 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) } else { - await viewer.restore({ table: source, settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG }) + await viewer.restore({ table: selectedView, settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG }) await plugin.restore(DEFAULT_PLUGIN_CONFIG) } await viewer.flush() @@ -205,7 +226,7 @@ export default function Pivot({ source }) { init() return () => { cancelled = true } - }, [source]) + }, [selectedView]) async function applyExpandDepth(viewer, depth) { if (depth == null) return @@ -226,8 +247,7 @@ export default function Pivot({ source }) { } await applyExpandDepth(viewer, layout.config.expand_depth ?? null) setActiveLayoutId(layout.id) - // also persist to localStorage so it survives refresh - localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config)) + localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config)) } async function captureConfig() { @@ -244,10 +264,15 @@ export default function Pivot({ source }) { const config = await captureConfig() if (!config) return try { - const saved = await api.savePivotLayout(source, layout.layout_name, config) - localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) + if (viewType === 'source') { + const saved = await api.savePivotLayout(selectedView, layout.layout_name, config) + setActiveLayoutId(saved.id) + } else { + const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config } : l) + localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated)) + } + localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config)) await loadLayouts() - setActiveLayoutId(saved.id) flashMsg('Saved!') } catch (err) { flashMsg(err.message) @@ -260,10 +285,18 @@ export default function Pivot({ source }) { const config = await captureConfig() if (!config) return try { - const saved = await api.savePivotLayout(source, name, config) - localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) + let newId + if (viewType === 'source') { + const saved = await api.savePivotLayout(selectedView, name, config) + newId = saved.id + } else { + newId = Date.now() + const updated = [...layouts, { id: newId, layout_name: name, config }] + localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated)) + } + localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config)) await loadLayouts() - setActiveLayoutId(saved.id) + setActiveLayoutId(newId) setShowSaveAs(false) setSaveAsName('') flashMsg('Saved!') @@ -275,7 +308,12 @@ export default function Pivot({ source }) { async function handleDelete(layout, e) { e.stopPropagation() try { - await api.deletePivotLayout(source, layout.id) + if (viewType === 'source') { + await api.deletePivotLayout(selectedView, layout.id) + } else { + const updated = layouts.filter(l => l.id !== layout.id) + localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated)) + } if (activeLayoutId === layout.id) setActiveLayoutId(null) await loadLayouts() flashMsg('Deleted') @@ -287,9 +325,9 @@ export default function Pivot({ source }) { function handleResetToDefault() { const viewer = viewerRef.current if (!viewer) return - localStorage.removeItem(LAYOUT_KEY(source)) + localStorage.removeItem(LAYOUT_KEY(selectedView)) setActiveLayoutId(null) - viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) + viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) } if (!source) return
Select a source first.
@@ -316,6 +354,21 @@ export default function Pivot({ source }) { {/* Layout toolbar */}
+ + {/* View selector */} +
+ + {stacks.map(s => ( + + ))} +
+ Layouts {layouts.map(l => ( diff --git a/ui/src/pages/Rules.jsx b/ui/src/pages/Rules.jsx index b9359a2..bcd8d0a 100644 --- a/ui/src/pages/Rules.jsx +++ b/ui/src/pages/Rules.jsx @@ -213,7 +213,7 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS ) } -export default function Rules({ source }) { +export default function Rules({ source, onStale }) { const [rules, setRules] = useState([]) const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) @@ -266,6 +266,7 @@ export default function Rules({ source }) { } else { await api.createRule({ ...form, source_name: source }) } + onStale?.(source) const updated = await api.getRules(source) setRules(updated) setCreating(false) @@ -282,6 +283,7 @@ export default function Rules({ source }) { if (!confirm('Delete this rule and all its mappings?')) return try { await api.deleteRule(id) + onStale?.(source) setRules(r => r.filter(x => x.id !== id)) setTestResults(t => { const n = { ...t }; delete n[id]; return n }) } catch (err) { @@ -301,6 +303,7 @@ export default function Rules({ source }) { async function handleToggle(rule) { try { await api.updateRule(rule.id, { enabled: !rule.enabled }) + onStale?.(source) setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x)) } catch (err) { alert(err.message) diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index fc6e4ad..e213fa8 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -79,7 +79,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { // ── Stack panel ──────────────────────────────────────────────────────────────── -function StackPanel({ stack, sources, onUpdated }) { +function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { const members = stack.sources || [] const [label, setLabel] = useState(stack.label || '') @@ -226,6 +226,7 @@ function StackPanel({ stack, sources, onUpdated }) { date_field: dateCanonical || null, }) setMappingsDirty(false) + onStale?.(stack.name) onUpdated() } catch (e) { setError(e.message) } finally { setSaving(false) } @@ -242,6 +243,7 @@ function StackPanel({ stack, sources, onUpdated }) { setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) })) } catch (e) {} setAddingSrc('') + onStale?.(stack.name) onUpdated() } @@ -276,6 +278,7 @@ function StackPanel({ stack, sources, onUpdated }) { async function removeSource(src) { await api.removeStackSource(stack.name, src) setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n }) + onStale?.(stack.name) onUpdated() } @@ -303,6 +306,7 @@ function StackPanel({ stack, sources, onUpdated }) { }) setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) setCalibratingSource(null) + onStale?.(stack.name) onUpdated() } @@ -312,7 +316,7 @@ function StackPanel({ stack, sources, onUpdated }) { try { const r = await api.generateStackView(stack.name) setViewResult(r) - if (r.success) fetchBalance() + if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) } } catch (e) { setError(e.message) } } @@ -570,7 +574,7 @@ function StackPanel({ stack, sources, onUpdated }) { // ── Main page ────────────────────────────────────────────────────────────────── -export default function Stacks({ sources }) { +export default function Stacks({ sources, onStackStale, onStackViewGenerated }) { const [stacks, setStacks] = useState([]) const [selected, setSelected] = useState(null) const [stackDetail, setStackDetail] = useState(null) @@ -651,6 +655,8 @@ export default function Stacks({ sources }) { stack={stackDetail} sources={sources} onUpdated={() => { load(); loadDetail(stackDetail.name) }} + onStale={onStackStale} + onViewGenerated={onStackViewGenerated} /> ) : (