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}
/>
>
) : (