Status bar, stale tracking, Pivot stack selector, stack view fixes

- Add get_status() SQL and /api/status route; load stale state on login
- Replace polling with immediate client-side stale tracking via callbacks
- Amber status bar with per-item Generate buttons for sources and stacks
- Pivot: add stack selector to view any dfv.stack view via Perspective
- Stack views: DROP CASCADE, add id to source views, per-source balance columns
- net_balance = sum(all amounts) + total_offset guarantees chase+dcard=net per row
- CLAUDE.md: document correct dedup spec (within-batch duplicates always allowed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-19 09:43:10 -04:00
parent f941c5ae4a
commit 7c63a2ac29
14 changed files with 321 additions and 57 deletions

View File

@ -124,9 +124,15 @@ records.data → apply_transformations() →
### Deduplication ### Deduplication
- `constraint_key` is a JSONB object of the constraint field values (readable, no hashing) - `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 - Dedup is enforced at import time via CTE — NO unique DB constraint on constraint_key
- Intra-file duplicate rows are allowed (bank may send identical rows); they all insert - **The constraint key is for cross-batch re-import protection, NOT record uniqueness**
- On re-import, all rows whose constraint_key already exists in the DB are skipped - 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) - Deleting an import log entry cascades to all records from that batch (import_id FK)
### Error Handling ### Error Handling

View File

@ -187,7 +187,11 @@ module.exports = (pool) => {
router.post('/:name/view', async (req, res, next) => { router.post('/:name/view', async (req, res, next) => {
try { try {
const result = await pool.query(`SELECT generate_source_view(${lit(req.params.name)}) as result`); 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) { } catch (err) {
next(err); next(err);
} }

View File

@ -102,7 +102,11 @@ module.exports = (pool) => {
router.post('/:name/view', async (req, res, next) => { router.post('/:name/view', async (req, res, next) => {
try { try {
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`); 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); } } catch (err) { next(err); }
}); });

14
api/routes/status.js Normal file
View File

@ -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;
};

View File

@ -55,6 +55,7 @@ const rulesRoutes = require('./routes/rules');
const mappingsRoutes = require('./routes/mappings'); const mappingsRoutes = require('./routes/mappings');
const recordsRoutes = require('./routes/records'); const recordsRoutes = require('./routes/records');
const stacksRoutes = require('./routes/stacks'); const stacksRoutes = require('./routes/stacks');
const statusRoutes = require('./routes/status');
// Mount routes // Mount routes
app.use('/api/sources', sourcesRoutes(pool)); 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/mappings', mappingsRoutes(pool));
app.use('/api/records', recordsRoutes(pool)); app.use('/api/records', recordsRoutes(pool));
app.use('/api/stacks', stacksRoutes(pool)); app.use('/api/stacks', stacksRoutes(pool));
app.use('/api/status', statusRoutes(pool));
// Health check // Health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@ -506,10 +506,10 @@ BEGIN
v_view := 'dfv.' || quote_ident(p_source_name); 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( 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 v_view, v_cols, p_source_name
); );

View File

@ -256,6 +256,8 @@ DECLARE
v_sql TEXT; v_sql TEXT;
v_has_bal BOOLEAN; v_has_bal BOOLEAN;
v_canon_cols TEXT; v_canon_cols TEXT;
v_src_bal_cols TEXT;
v_total_offset NUMERIC := 0;
BEGIN BEGIN
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
IF NOT FOUND THEN IF NOT FOUND THEN
@ -304,20 +306,21 @@ BEGIN
END IF; END IF;
END LOOP; 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_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_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); 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; END LOOP;
IF array_length(v_ctes, 1) IS NULL THEN IF array_length(v_ctes, 1) IS NULL THEN
@ -326,7 +329,7 @@ BEGIN
CREATE SCHEMA IF NOT EXISTS dfv; CREATE SCHEMA IF NOT EXISTS dfv;
v_view := 'dfv.' || quote_ident(p_stack_name); 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 := ( v_canon_cols := (
SELECT string_agg(quote_ident(f->>'name'), ', ') SELECT string_agg(quote_ident(f->>'name'), ', ')
@ -334,20 +337,24 @@ BEGIN
); );
IF v_has_bal THEN 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( v_sql := format(
'CREATE VIEW %s AS ' 'CREATE VIEW %s AS '
'WITH %s, _stacked AS (SELECT * FROM %s) ' 'WITH %s, _stacked AS (SELECT * FROM %s) '
'SELECT _source, _id, %s, source_balance, ' 'SELECT _source, _id, %s, '
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance ' 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, '
'FROM _stacked', '%s '
'FROM _stacked ORDER BY %I DESC, _id DESC',
v_view, v_view,
array_to_string(v_ctes, ', '), array_to_string(v_ctes, ', '),
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
v_canon_cols, v_canon_cols,
v_stack.amount_field, v_stack.amount_field,
v_stack.date_field, v_stack.date_field,
v_stack.balance_offset v_total_offset,
v_src_bal_cols,
v_stack.date_field
); );
ELSE ELSE
v_sql := format( v_sql := format(

View File

@ -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;

View File

@ -30,6 +30,10 @@ export default function App() {
const [sources, setSources] = useState([]) const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [sidebarOpen, setSidebarOpen] = useState(false) 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) { async function handleLogin(user, pass) {
setCredentials(user, pass) setCredentials(user, pass)
@ -50,6 +54,45 @@ export default function App() {
setAuthed(false) setAuthed(false)
setLoginUser('') setLoginUser('')
setSources([]) 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 // On mount, restore session if credentials are saved
@ -141,17 +184,48 @@ export default function App() {
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span> <span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
</div> </div>
{(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1">
{name}
<button
onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
>
{generating[`src:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
{[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1">
stack: {name}
<button
onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
>
{generating[`stk:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
</div>
)}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Routes> <Routes>
<Route path="/" element={<Navigate to="/sources" replace />} /> <Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} /> <Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/import" element={<Import source={source} />} /> <Route path="/import" element={<Import source={source} />} />
<Route path="/rules" element={<Rules source={source} />} /> <Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
<Route path="/mappings" element={<Mappings source={source} />} /> <Route path="/mappings" element={<Mappings source={source} onStale={markSourceStale} />} />
<Route path="/remap" element={<Remap />} /> <Route path="/remap" element={<Remap />} />
<Route path="/records" element={<Records source={source} />} /> <Route path="/records" element={<Records source={source} />} />
<Route path="/pivot" element={<Pivot source={source} />} /> <Route path="/pivot" element={<Pivot source={source} />} />
<Route path="/stacks" element={<Stacks sources={sources} />} /> <Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
</Routes> </Routes>
</div> </div>

View File

@ -125,6 +125,9 @@ export const api = {
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`), getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }), calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
// Status
getStatus: () => request('GET', '/status'),
// Records // Records
getRecords: (source, limit = 100, offset = 0) => getRecords: (source, limit = 100, offset = 0) =>
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),

View File

@ -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 [rules, setRules] = useState([])
const [selectedRule, setSelectedRule] = useState('') const [selectedRule, setSelectedRule] = useState('')
const [allValues, setAllValues] = 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 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 }) setDrafts(d => { const n = { ...d }; delete n[k]; return n })
} catch (err) { } catch (err) {
alert(err.message) alert(err.message)
@ -309,6 +310,7 @@ export default function Mappings({ source }) {
setSaving(s => ({ ...s, [k]: false })) setSaving(s => ({ ...s, [k]: false }))
} }
})) }))
onStale?.(source)
setSelected(new Set()) setSelected(new Set())
setBulkDraft({}) setBulkDraft({})
} }
@ -317,6 +319,7 @@ export default function Mappings({ source }) {
if (!row.mapping_id) return if (!row.mapping_id) return
try { try {
await api.deleteMapping(row.mapping_id) await api.deleteMapping(row.mapping_id)
onStale?.(source)
setAllValues(av => av.map(x => setAllValues(av => av.map(x =>
valueKey(x.extracted_value) === valueKey(row.extracted_value) valueKey(x.extracted_value) === valueKey(row.extracted_value)
? { ...x, is_mapped: false, mapping_id: null, output: null } ? { ...x, is_mapped: false, mapping_id: null, output: null }

View File

@ -92,7 +92,22 @@ export default function Pivot({ source }) {
const [clickDetail, setClickDetail] = useState(null) const [clickDetail, setClickDetail] = useState(null)
const [decimals, setDecimals] = useState(2) 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 [layouts, setLayouts] = useState([])
const [activeLayoutId, setActiveLayoutId] = useState(null) const [activeLayoutId, setActiveLayoutId] = useState(null)
const [saveAsName, setSaveAsName] = useState('') const [saveAsName, setSaveAsName] = useState('')
@ -105,15 +120,21 @@ export default function Pivot({ source }) {
} }
const loadLayouts = useCallback(async () => { const loadLayouts = useCallback(async () => {
if (!source) return if (!selectedView) return
try { try {
const rows = await api.getPivotLayouts(source) if (viewType === 'source') {
const rows = await api.getPivotLayouts(selectedView)
setLayouts(rows) setLayouts(rows)
} else {
// Stacks: localStorage only
const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`)
setLayouts(stored ? JSON.parse(stored) : [])
}
} catch {} } catch {}
}, [source]) }, [selectedView, viewType])
useEffect(() => { useEffect(() => {
if (!source) return if (!selectedView) return
let cancelled = false let cancelled = false
setInspectedRows(null) setInspectedRows(null)
setClickDetail(null) setClickDetail(null)
@ -129,7 +150,7 @@ export default function Pivot({ source }) {
try { try {
const [perspective, rows] = await Promise.all([ const [perspective, rows] = await Promise.all([
loadPerspective(), loadPerspective(),
fetchAllRows(source), fetchAllRows(selectedView),
]) ])
if (cancelled) return if (cancelled) return
if (!rows.length) { setStatus('noview'); return } if (!rows.length) { setStatus('noview'); return }
@ -142,7 +163,7 @@ export default function Pivot({ source }) {
if (cancelled) { worker.terminate(); return } if (cancelled) { worker.terminate(); return }
workerRef.current = worker workerRef.current = worker
const table = await worker.table(rows, { name: source }) const table = await worker.table(rows, { name: selectedView })
if (cancelled) return if (cancelled) return
tableRef.current = table tableRef.current = table
@ -185,14 +206,14 @@ export default function Pivot({ source }) {
await viewer.load(worker) await viewer.load(worker)
const plugin = await viewer.getPlugin() const plugin = await viewer.getPlugin()
const savedLayout = localStorage.getItem(LAYOUT_KEY(source)) const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
if (savedLayout) { if (savedLayout) {
const parsed = JSON.parse(savedLayout) const parsed = JSON.parse(savedLayout)
await viewer.restore(parsed) await viewer.restore(parsed)
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG) await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth) if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
} else { } 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 plugin.restore(DEFAULT_PLUGIN_CONFIG)
} }
await viewer.flush() await viewer.flush()
@ -205,7 +226,7 @@ export default function Pivot({ source }) {
init() init()
return () => { cancelled = true } return () => { cancelled = true }
}, [source]) }, [selectedView])
async function applyExpandDepth(viewer, depth) { async function applyExpandDepth(viewer, depth) {
if (depth == null) return if (depth == null) return
@ -226,8 +247,7 @@ export default function Pivot({ source }) {
} }
await applyExpandDepth(viewer, layout.config.expand_depth ?? null) await applyExpandDepth(viewer, layout.config.expand_depth ?? null)
setActiveLayoutId(layout.id) setActiveLayoutId(layout.id)
// also persist to localStorage so it survives refresh localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config))
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config))
} }
async function captureConfig() { async function captureConfig() {
@ -244,10 +264,15 @@ export default function Pivot({ source }) {
const config = await captureConfig() const config = await captureConfig()
if (!config) return if (!config) return
try { try {
const saved = await api.savePivotLayout(source, layout.layout_name, config) if (viewType === 'source') {
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) const saved = await api.savePivotLayout(selectedView, layout.layout_name, config)
await loadLayouts()
setActiveLayoutId(saved.id) 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()
flashMsg('Saved!') flashMsg('Saved!')
} catch (err) { } catch (err) {
flashMsg(err.message) flashMsg(err.message)
@ -260,10 +285,18 @@ export default function Pivot({ source }) {
const config = await captureConfig() const config = await captureConfig()
if (!config) return if (!config) return
try { try {
const saved = await api.savePivotLayout(source, name, config) let newId
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) 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() await loadLayouts()
setActiveLayoutId(saved.id) setActiveLayoutId(newId)
setShowSaveAs(false) setShowSaveAs(false)
setSaveAsName('') setSaveAsName('')
flashMsg('Saved!') flashMsg('Saved!')
@ -275,7 +308,12 @@ export default function Pivot({ source }) {
async function handleDelete(layout, e) { async function handleDelete(layout, e) {
e.stopPropagation() e.stopPropagation()
try { 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) if (activeLayoutId === layout.id) setActiveLayoutId(null)
await loadLayouts() await loadLayouts()
flashMsg('Deleted') flashMsg('Deleted')
@ -287,9 +325,9 @@ export default function Pivot({ source }) {
function handleResetToDefault() { function handleResetToDefault() {
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return if (!viewer) return
localStorage.removeItem(LAYOUT_KEY(source)) localStorage.removeItem(LAYOUT_KEY(selectedView))
setActiveLayoutId(null) 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 <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
@ -316,6 +354,21 @@ export default function Pivot({ source }) {
{/* Layout toolbar */} {/* Layout toolbar */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
{/* View selector */}
<div className="flex items-center gap-1 mr-2 border-r border-gray-200 pr-3">
<button
onClick={selectSource}
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'source' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
>{source}</button>
{stacks.map(s => (
<button key={s.name}
onClick={() => selectStack(s.name)}
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'stack' && selectedView === s.name ? 'bg-purple-50 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
>{s.name}</button>
))}
</div>
<span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span> <span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
{layouts.map(l => ( {layouts.map(l => (

View File

@ -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 [rules, setRules] = useState([])
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
@ -266,6 +266,7 @@ export default function Rules({ source }) {
} else { } else {
await api.createRule({ ...form, source_name: source }) await api.createRule({ ...form, source_name: source })
} }
onStale?.(source)
const updated = await api.getRules(source) const updated = await api.getRules(source)
setRules(updated) setRules(updated)
setCreating(false) setCreating(false)
@ -282,6 +283,7 @@ export default function Rules({ source }) {
if (!confirm('Delete this rule and all its mappings?')) return if (!confirm('Delete this rule and all its mappings?')) return
try { try {
await api.deleteRule(id) await api.deleteRule(id)
onStale?.(source)
setRules(r => r.filter(x => x.id !== id)) setRules(r => r.filter(x => x.id !== id))
setTestResults(t => { const n = { ...t }; delete n[id]; return n }) setTestResults(t => { const n = { ...t }; delete n[id]; return n })
} catch (err) { } catch (err) {
@ -301,6 +303,7 @@ export default function Rules({ source }) {
async function handleToggle(rule) { async function handleToggle(rule) {
try { try {
await api.updateRule(rule.id, { enabled: !rule.enabled }) await api.updateRule(rule.id, { enabled: !rule.enabled })
onStale?.(source)
setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x)) setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))
} catch (err) { } catch (err) {
alert(err.message) alert(err.message)

View File

@ -79,7 +79,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
// Stack panel // Stack panel
function StackPanel({ stack, sources, onUpdated }) { function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
const members = stack.sources || [] const members = stack.sources || []
const [label, setLabel] = useState(stack.label || '') const [label, setLabel] = useState(stack.label || '')
@ -226,6 +226,7 @@ function StackPanel({ stack, sources, onUpdated }) {
date_field: dateCanonical || null, date_field: dateCanonical || null,
}) })
setMappingsDirty(false) setMappingsDirty(false)
onStale?.(stack.name)
onUpdated() onUpdated()
} catch (e) { setError(e.message) } } catch (e) { setError(e.message) }
finally { setSaving(false) } finally { setSaving(false) }
@ -242,6 +243,7 @@ function StackPanel({ stack, sources, onUpdated }) {
setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) })) setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) }))
} catch (e) {} } catch (e) {}
setAddingSrc('') setAddingSrc('')
onStale?.(stack.name)
onUpdated() onUpdated()
} }
@ -276,6 +278,7 @@ function StackPanel({ stack, sources, onUpdated }) {
async function removeSource(src) { async function removeSource(src) {
await api.removeStackSource(stack.name, src) await api.removeStackSource(stack.name, src)
setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n }) setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n })
onStale?.(stack.name)
onUpdated() onUpdated()
} }
@ -303,6 +306,7 @@ function StackPanel({ stack, sources, onUpdated }) {
}) })
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
setCalibratingSource(null) setCalibratingSource(null)
onStale?.(stack.name)
onUpdated() onUpdated()
} }
@ -312,7 +316,7 @@ function StackPanel({ stack, sources, onUpdated }) {
try { try {
const r = await api.generateStackView(stack.name) const r = await api.generateStackView(stack.name)
setViewResult(r) setViewResult(r)
if (r.success) fetchBalance() if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) }
} catch (e) { setError(e.message) } } catch (e) { setError(e.message) }
} }
@ -570,7 +574,7 @@ function StackPanel({ stack, sources, onUpdated }) {
// Main page // Main page
export default function Stacks({ sources }) { export default function Stacks({ sources, onStackStale, onStackViewGenerated }) {
const [stacks, setStacks] = useState([]) const [stacks, setStacks] = useState([])
const [selected, setSelected] = useState(null) const [selected, setSelected] = useState(null)
const [stackDetail, setStackDetail] = useState(null) const [stackDetail, setStackDetail] = useState(null)
@ -651,6 +655,8 @@ export default function Stacks({ sources }) {
stack={stackDetail} stack={stackDetail}
sources={sources} sources={sources}
onUpdated={() => { load(); loadDetail(stackDetail.name) }} onUpdated={() => { load(); loadDetail(stackDetail.name) }}
onStale={onStackStale}
onViewGenerated={onStackViewGenerated}
/> />
</> </>
) : ( ) : (