From f941c5ae4a0aae764b346bd15698bac5a12a1419 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 18 Apr 2026 21:17:12 -0400 Subject: [PATCH] Stacks: per-source amount/date fields, mapping grid UI, dfv view generation with source balance CTEs - SQL: upsert_stack_source gains amount_field/date_field params; calibrate_balance queries dfv.{source} directly (no stack view needed); generate_stack_view builds per-source CTEs with source_balance, outer net_balance; information_schema check for missing columns - API: pass amount_field/date_field through upsert route; calibrate accepts source_name - UI: mapping grid table (rows=fields, cols=sources); per-source amount/date/sign in Sources section; auto-populate output columns on first source config; horizontal stack chips above full-width config panel; calibration auto-saves before opening, editable offset input Co-Authored-By: Claude Sonnet 4.6 --- api/routes/stacks.js | 5 +- database/queries/stacks.sql | 216 +++++++------ ui/src/pages/Stacks.jsx | 621 ++++++++++++++++++++++-------------- 3 files changed, 516 insertions(+), 326 deletions(-) diff --git a/api/routes/stacks.js b/api/routes/stacks.js index e4b56e3..75f1b66 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -67,9 +67,10 @@ module.exports = (pool) => { // Add or update a source in a stack router.put('/:name/sources/:source', async (req, res, next) => { try { - const { field_map, amount_sign, balance_offset } = req.body; + const { field_map, amount_sign, balance_offset, amount_field, date_field } = req.body; + const n = v => v != null ? lit(v) : 'NULL'; const result = await pool.query( - `SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)})` + `SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)}, ${n(amount_field)}, ${n(date_field)})` ); res.json(result.rows[0]); } catch (err) { diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index d805060..8185319 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -26,20 +26,26 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources ( id SERIAL PRIMARY KEY, stack_name TEXT NOT NULL REFERENCES dataflow.stacks(name) ON DELETE CASCADE, source_name TEXT NOT NULL REFERENCES dataflow.sources(name) ON DELETE CASCADE, - -- Maps canonical field name → field name in records.transformed + -- Maps other canonical field names → source view column names (not amount/date — those are explicit) field_map JSONB NOT NULL DEFAULT '{}', - -- Multiply amount by this before summing (1 = as-is, -1 = flip sign) + -- Which column in dfv.{source} is the amount, and its sign (+1/-1) + amount_field TEXT, amount_sign INTEGER NOT NULL DEFAULT 1, - -- Seed balance for this source — added as a constant to the combined running total + -- Which column in dfv.{source} is the date + date_field TEXT, + -- Calibration offset added to this source's running balance balance_offset NUMERIC NOT NULL DEFAULT 0, UNIQUE (stack_name, source_name) ); -- Migrations: add columns that may be missing from earlier deploys ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS balance_offset NUMERIC NOT NULL DEFAULT 0; +ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS amount_field TEXT; +ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT; --- Drop old 3-arg calibrate_balance signature if it exists before recreating with 4 args +-- Drop old signatures before recreating DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); +DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC); ------------------------------------------------------ -- Function: list_stacks @@ -89,7 +95,9 @@ RETURNS TABLE ( 'id', ss.id, 'source_name', ss.source_name, 'field_map', ss.field_map, + 'amount_field', ss.amount_field, 'amount_sign', ss.amount_sign, + 'date_field', ss.date_field, 'balance_offset', ss.balance_offset ) ORDER BY ss.source_name ) FILTER (WHERE ss.id IS NOT NULL), '[]') @@ -152,14 +160,18 @@ CREATE OR REPLACE FUNCTION upsert_stack_source( p_source_name TEXT, p_field_map JSONB DEFAULT '{}', p_amount_sign INTEGER DEFAULT 1, - p_balance_offset NUMERIC DEFAULT 0 + p_balance_offset NUMERIC DEFAULT 0, + p_amount_field TEXT DEFAULT NULL, + p_date_field TEXT DEFAULT NULL ) RETURNS dataflow.stack_sources AS $$ - INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset) - VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset) + INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset, amount_field, date_field) + VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset, p_amount_field, p_date_field) ON CONFLICT (stack_name, source_name) DO UPDATE SET field_map = EXCLUDED.field_map, amount_sign = EXCLUDED.amount_sign, - balance_offset = EXCLUDED.balance_offset + balance_offset = EXCLUDED.balance_offset, + amount_field = EXCLUDED.amount_field, + date_field = EXCLUDED.date_field RETURNING *; $$ LANGUAGE sql; @@ -175,47 +187,40 @@ $$ LANGUAGE sql; ------------------------------------------------------ -- Function: calibrate_balance --- Given a known good balance at a specific date, compute the offset needed. --- Returns: {computed_at_date, known_balance, suggested_offset} +-- Queries dfv.{source} directly using per-source amount/date fields. +-- No stack view required. ------------------------------------------------------ CREATE OR REPLACE FUNCTION calibrate_balance( p_stack_name TEXT, - p_source_name TEXT, -- specific source to calibrate, or NULL for combined + p_source_name TEXT, p_as_of_date DATE, p_known_balance NUMERIC ) RETURNS JSON AS $$ DECLARE - v_stack dataflow.stacks%ROWTYPE; - v_running NUMERIC := 0; - v_other_offsets NUMERIC := 0; + v_src dataflow.stack_sources%ROWTYPE; + v_running NUMERIC; + v_sql TEXT; BEGIN - SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; + SELECT * INTO v_src + FROM dataflow.stack_sources + WHERE stack_name = p_stack_name AND source_name = p_source_name; + IF NOT FOUND THEN - RETURN json_build_object('success', false, 'error', 'Stack not found'); + RETURN json_build_object('success', false, 'error', 'Source not in stack'); END IF; - IF v_stack.amount_field IS NULL OR v_stack.date_field IS NULL THEN - RETURN json_build_object('success', false, 'error', 'Stack must have amount_field and date_field set'); + IF v_src.amount_field IS NULL OR v_src.date_field IS NULL THEN + RETURN json_build_object('success', false, 'error', 'Set amount and date fields on this source first'); END IF; - -- Sum amount * sign for the target source(s) up to as_of_date - SELECT COALESCE(SUM( - (rec.transformed ->> (ss.field_map ->> v_stack.amount_field))::numeric - * ss.amount_sign - ), 0) - INTO v_running - FROM dataflow.stack_sources ss - JOIN dataflow.records rec ON rec.source_name = ss.source_name - WHERE ss.stack_name = p_stack_name - AND (p_source_name IS NULL OR ss.source_name = p_source_name) - AND rec.transformed IS NOT NULL - AND (rec.transformed ->> (ss.field_map ->> v_stack.date_field))::date <= p_as_of_date; - - -- For combined calibration, include existing offsets from other sources - IF p_source_name IS NOT NULL THEN - SELECT COALESCE(SUM(balance_offset), 0) INTO v_other_offsets - FROM dataflow.stack_sources - WHERE stack_name = p_stack_name AND source_name != p_source_name; - 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 + ); + 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'); + END; RETURN json_build_object( 'success', true, @@ -223,7 +228,6 @@ BEGIN 'as_of_date', p_as_of_date, 'known_balance', p_known_balance, 'computed_sum', v_running, - 'other_offsets', v_other_offsets, 'suggested_offset', p_known_balance - v_running ); END; @@ -231,22 +235,27 @@ $$ LANGUAGE plpgsql STABLE; ------------------------------------------------------ -- Function: generate_stack_view --- Builds a UNION ALL view in dfv schema from all member sources. --- Includes running_balance if amount_field and date_field are set. +-- Builds a WITH ... UNION ALL view in dfv schema from existing dfv source views. +-- Each source CTE applies amount_sign and computes a per-source running balance. +-- Outer SELECT adds net_balance across all sources. ------------------------------------------------------ CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT) RETURNS JSON AS $$ DECLARE - v_stack dataflow.stacks%ROWTYPE; - v_src dataflow.stack_sources%ROWTYPE; - v_field JSONB; - v_parts TEXT[] := '{}'; - v_select TEXT; - v_col TEXT; - v_src_field TEXT; - v_view TEXT; - v_sql TEXT; - v_has_bal BOOLEAN; + v_stack dataflow.stacks%ROWTYPE; + v_src dataflow.stack_sources%ROWTYPE; + v_field JSONB; + v_ctes TEXT[] := '{}'; + v_cte_names TEXT[] := '{}'; + v_select TEXT; + v_col TEXT; + 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; BEGIN SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; IF NOT FOUND THEN @@ -255,40 +264,63 @@ BEGIN v_has_bal := v_stack.amount_field IS NOT NULL AND v_stack.date_field IS NOT NULL; - -- Build one SELECT per source + -- Build one CTE per source querying dfv.{source} directly FOR v_src IN SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name LOOP - v_select := format('SELECT %L AS _source, rec.id AS _id', v_src.source_name); + v_select := format('SELECT %L AS _source, id AS _id', v_src.source_name); - -- Canonical fields, mapped to source field names FOR v_field IN SELECT * FROM jsonb_array_elements(v_stack.fields) LOOP - v_col := v_field->>'name'; - v_src_field := COALESCE(v_src.field_map->>v_col, v_col); + v_col := v_field->>'name'; - v_select := v_select || ', ' || CASE v_field->>'type' - WHEN 'numeric' THEN - format('(rec.transformed->>%L)::numeric AS %I', v_src_field, v_col) - WHEN 'date' THEN - format('(rec.transformed->>%L)::date AS %I', v_src_field, v_col) + IF v_has_bal AND v_col = v_stack.amount_field THEN + -- Use per-source amount_field with sign applied + IF v_src.amount_field IS NULL THEN + v_select := v_select || format(', NULL::%s AS %I', v_field->>'type', v_col); ELSE - format('rec.transformed->>%L AS %I', v_src_field, v_col) - END; + v_select := v_select || format(', %I * %s AS %I', v_src.amount_field, v_src.amount_sign, v_col); + END IF; + ELSIF v_has_bal AND v_col = v_stack.date_field THEN + -- Use per-source date_field + IF v_src.date_field IS NULL THEN + v_select := v_select || format(', NULL::date AS %I', v_col); + ELSE + v_select := v_select || format(', %I AS %I', v_src.date_field, v_col); + END IF; + ELSE + -- Other canonical fields: use field_map or same name, NULL if column doesn't exist + v_src_field := COALESCE(v_src.field_map->>v_col, v_col); + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'dfv' + AND table_name = v_src.source_name + AND column_name = v_src_field + ) THEN + v_select := v_select || format(', %I AS %I', v_src_field, v_col); + ELSE + v_select := v_select || format(', NULL::text AS %I', v_col); + END IF; + END IF; END LOOP; - -- amount_sign column for running balance - v_select := v_select || format(', %s::integer AS _sign, %s::numeric AS _src_offset', v_src.amount_sign, v_src.balance_offset); + -- 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 dataflow.records rec WHERE rec.source_name = %L AND rec.transformed IS NOT NULL', - v_src.source_name - ); + v_select := v_select || format(' FROM dfv.%I', v_src.source_name); - v_parts := v_parts || 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); END LOOP; - IF array_length(v_parts, 1) IS NULL THEN + IF array_length(v_ctes, 1) IS NULL THEN RETURN json_build_object('success', false, 'error', 'Stack has no sources'); END IF; @@ -296,38 +328,36 @@ BEGIN v_view := 'dfv.' || quote_ident(p_stack_name); EXECUTE format('DROP VIEW IF EXISTS %s', v_view); - -- Wrap in outer SELECT that adds running_balance if configured + v_canon_cols := ( + SELECT string_agg(quote_ident(f->>'name'), ', ') + FROM jsonb_array_elements(v_stack.fields) f + ); + IF v_has_bal THEN - -- running_balance = cumulative sum of (amount * sign) + per-source seed offsets + stack-level offset - -- Each row's _src_offset is that source's balance_offset, summed cumulatively so it's added once - -- per source in the window rather than per row. We use a trick: sum(_src_offset / count_of_source_rows) - -- is complex, so instead we add SUM(DISTINCT _src_offset per _source) as a constant via subquery. - -- Simpler: treat each source offset as a lump added to its first row only via ROW_NUMBER trick. - -- Cleanest: add total of all source offsets as a constant (valid when each source is calibrated - -- relative to its own transaction history, not to each other). + -- net_balance: cumulative signed amount across all sources + stack-level offset v_sql := format( 'CREATE VIEW %s AS ' - 'SELECT _source, _id, %s, ' - 'SUM((%I)::numeric * _sign) OVER (ORDER BY %I ASC, _id ASC) ' - '+ (SELECT COALESCE(SUM(balance_offset),0) FROM dataflow.stack_sources WHERE stack_name = %L) ' - '+ %s AS running_balance ' - 'FROM (%s) _stacked', + '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', v_view, - (SELECT string_agg(quote_ident(f->>'name'), ', ') - FROM jsonb_array_elements(v_stack.fields) f), + 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, - p_stack_name, - v_stack.balance_offset, - array_to_string(v_parts, ' UNION ALL ') + v_stack.balance_offset ); ELSE v_sql := format( - 'CREATE VIEW %s AS SELECT _source, _id, %s FROM (%s) _stacked', + 'CREATE VIEW %s AS ' + 'WITH %s, _stacked AS (SELECT * FROM %s) ' + 'SELECT _source, _id, %s FROM _stacked', v_view, - (SELECT string_agg(quote_ident(f->>'name'), ', ') - FROM jsonb_array_elements(v_stack.fields) f), - array_to_string(v_parts, ' UNION ALL ') + array_to_string(v_ctes, ', '), + array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '), + v_canon_cols ); END IF; @@ -361,7 +391,7 @@ BEGIN BEGIN v_sql := format( - 'SELECT running_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1', + 'SELECT net_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1', v_view, v_stack.date_field ); EXECUTE v_sql INTO v_balance; diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index 990059a..fc6e4ad 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -24,14 +24,14 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { } return ( -
+
{ if (e.target === e.currentTarget) onClose() }}>
e.stopPropagation()}>
- Calibrate balance + Calibrate — {sourceName}

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

@@ -49,9 +49,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { {loading ? 'Calculating…' : 'Calculate'} {error &&

{error}

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

{result.error}

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

{result.error}

} {result && result.success && (
Computed sum at date{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}
@@ -67,8 +65,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
- @@ -80,124 +77,68 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { ) } -// ── Source member row ────────────────────────────────────────────────────────── +// ── Stack panel ──────────────────────────────────────────────────────────────── -function SourceRow({ stack, member, stackFields, onSave, onRemove }) { - const [map, setMap] = useState(member.field_map || {}) - const [sign, setSign] = useState(member.amount_sign ?? 1) - const [offset, setOffset] = useState(member.balance_offset ?? 0) - const [dirty, setDirty] = useState(false) - const [srcFields, setSrcFields] = useState([]) - const [calibrating, setCalibrating] = useState(false) +function StackPanel({ stack, sources, onUpdated }) { + const members = stack.sources || [] - useEffect(() => { - api.getFields(member.source_name).then(f => setSrcFields(f.map(x => x.key))).catch(() => {}) - }, [member.source_name]) - - function setMapping(canonical, srcField) { setMap(m => ({ ...m, [canonical]: srcField })); setDirty(true) } - function handleSign(v) { setSign(v); setDirty(true) } - function handleOffset(v) { setOffset(v); setDirty(true) } - - function save() { onSave(member.source_name, map, sign, offset); setDirty(false) } - - function applyCalibration(suggestedOffset) { - setOffset(suggestedOffset) - setDirty(true) - setCalibrating(false) - } - - return ( -
-
- {member.source_name} -
- {dirty && ( - - )} - -
-
- - {/* Amount sign + balance offset */} -
-
- - -
-
- - handleOffset(parseFloat(e.target.value) || 0)} - className="w-28 border border-gray-200 rounded px-2 py-0.5 font-mono focus:outline-none focus:border-blue-400" /> - -
-
- - {/* Field mappings */} -
- Stack field - Source field - {stackFields.map(f => ( - <> - {f.name} - - - ))} -
- - {calibrating && ( - setCalibrating(false)} onApply={applyCalibration} /> - )} -
- ) -} - -// ── Stack detail / edit panel ────────────────────────────────────────────────── - -function StackPanel({ stack, sources, onUpdated, onDeleted }) { const [label, setLabel] = useState(stack.label || '') const [fields, setFields] = useState(stack.fields || []) - const [amountField, setAmountField] = useState(stack.amount_field || '') - const [dateField, setDateField] = useState(stack.date_field || '') - const [members, setMembers] = useState(stack.sources || []) const [newField, setNewField] = useState({ name: '', type: 'text' }) const [addingSrc, setAddingSrc] = useState('') + + // Per-source config: sign, offset, amount_field, date_field, field_map + const [srcCfg, setSrcCfg] = useState(() => + Object.fromEntries(members.map(m => [m.source_name, { + sign: m.amount_sign ?? 1, + offset: m.balance_offset ?? 0, + amount_field: m.amount_field || '', + date_field: m.date_field || '', + field_map: { ...(m.field_map || {}) }, + }])) + ) + + // Available columns from each source's dfv view + const [srcFields, setSrcFields] = useState({}) + + // Drag-to-reorder state + const [dragIdx, setDragIdx] = useState(null) + const [dragOverIdx, setDragOverIdx] = useState(null) + + // Calibrate + const [calibratingSource, setCalibratingSource] = useState(null) + + // View / balance const [viewResult, setViewResult] = useState(null) - const [currentBalance, setCurrentBalance] = useState(null) + const [netBalance, setNetBalance] = useState(null) const [balanceError, setBalanceError] = useState('') + const [saving, setSaving] = useState(false) + const [mappingsDirty, setMappingsDirty] = useState(false) const [error, setError] = useState('') - async function saveStack(updates) { + // Fetch source columns whenever members change + useEffect(() => { + members.forEach(m => { + api.getFields(m.source_name) + .then(f => setSrcFields(prev => ({ ...prev, [m.source_name]: f.map(x => x.key) }))) + .catch(() => {}) + }) + }, [members.map(m => m.source_name).join(',')]) + + // Auto-detect canonical amount/date field from field types + const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field + const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field + + // ── Label ── + async function saveLabel() { setSaving(true); setError('') - try { - await api.updateStack(stack.name, updates) - onUpdated() - } catch (e) { setError(e.message) } + try { await api.updateStack(stack.name, { label }); onUpdated() } + catch (e) { setError(e.message) } finally { setSaving(false) } } - async function saveLabel() { - await saveStack({ label, amount_field: amountField, date_field: dateField, fields }) - } - - async function saveBalance() { - await saveStack({ label, amount_field: amountField, date_field: dateField, fields }) - } - + // ── Fields ── async function addField() { if (!newField.name) return const updated = [...fields, { name: newField.name, type: newField.type }] @@ -207,32 +148,167 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { onUpdated() } - function removeField(name) { + async function removeField(name) { const updated = fields.filter(f => f.name !== name) setFields(updated) - if (amountField === name) setAmountField('') - if (dateField === name) setDateField('') - api.updateStack(stack.name, { fields: updated }).then(onUpdated) + await api.updateStack(stack.name, { fields: updated }) + onUpdated() } + // ── Drag reorder ── + function handleDragStart(e, idx) { + setDragIdx(idx) + e.dataTransfer.effectAllowed = 'move' + } + + function handleDragOver(e, idx) { + e.preventDefault() + setDragOverIdx(idx) + } + + async function handleDrop(e, toIdx) { + e.preventDefault() + if (dragIdx === null || dragIdx === toIdx) { setDragIdx(null); setDragOverIdx(null); return } + const updated = [...fields] + const [moved] = updated.splice(dragIdx, 1) + updated.splice(toIdx, 0, moved) + setFields(updated) + setDragIdx(null); setDragOverIdx(null) + await api.updateStack(stack.name, { fields: updated }) + onUpdated() + } + + // ── Mapping grid ── + function getMappingValue(srcName, canonicalName) { + const cfg = srcCfg[srcName] || {} + if (canonicalName === amountCanonical) return cfg.amount_field || '' + if (canonicalName === dateCanonical) return cfg.date_field || '' + return cfg.field_map?.[canonicalName] || '' + } + + function setMappingValue(srcName, canonicalName, value) { + setSrcCfg(prev => { + const cfg = { ...prev[srcName] } + if (canonicalName === amountCanonical) cfg.amount_field = value + else if (canonicalName === dateCanonical) cfg.date_field = value + else cfg.field_map = { ...cfg.field_map, [canonicalName]: value } + return { ...prev, [srcName]: cfg } + }) + setMappingsDirty(true) + } + + function setSrcSign(srcName, sign) { + setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], sign } })) + setMappingsDirty(true) + } + + function setSrcOffset(srcName, offset) { + setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) + setMappingsDirty(true) + } + + async function saveMappings() { + setSaving(true); setError('') + 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, + }) + } + // Persist the auto-detected canonical field names on the stack + await api.updateStack(stack.name, { + amount_field: amountCanonical || null, + date_field: dateCanonical || null, + }) + setMappingsDirty(false) + onUpdated() + } catch (e) { setError(e.message) } + finally { setSaving(false) } + } + + // ── Sources ── async function addSource() { if (!addingSrc) return await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 }) - setMembers(m => [...m.filter(x => x.source_name !== addingSrc), { source_name: addingSrc, field_map: {}, amount_sign: 1 }]) + setSrcCfg(prev => ({ ...prev, [addingSrc]: { sign: 1, offset: 0, amount_field: '', date_field: '', field_map: {} } })) + // Load fields immediately so dropdowns are ready + try { + const f = await api.getFields(addingSrc) + setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) })) + } catch (e) {} setAddingSrc('') + onUpdated() } - async function saveSource(src, field_map, amount_sign, balance_offset) { - await api.upsertStackSource(stack.name, src, { field_map, amount_sign, balance_offset }) + function handleSrcAmountField(srcName, value) { + setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], amount_field: value } })) + // Update column type to numeric if a column with this name exists + setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'numeric' } : f)) + setMappingsDirty(true) + maybeAutoPopulate(srcName, value, srcCfg[srcName]?.date_field) + } + + function handleSrcDateField(srcName, value) { + setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], date_field: value } })) + setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'date' } : f)) + setMappingsDirty(true) + maybeAutoPopulate(srcName, srcCfg[srcName]?.amount_field, value) + } + + function maybeAutoPopulate(srcName, amtField, dtField) { + if (!amtField || !dtField) return + if (fields.length > 0) return // don't overwrite existing columns + const sourceFields = srcFields[srcName] || [] + if (sourceFields.length === 0) return + const newFields = sourceFields.map(sf => ({ + name: sf, + type: sf === amtField ? 'numeric' : sf === dtField ? 'date' : 'text', + })) + setFields(newFields) + api.updateStack(stack.name, { fields: newFields, amount_field: amtField, date_field: dtField }) } async function removeSource(src) { await api.removeStackSource(stack.name, src) - setMembers(m => m.filter(x => x.source_name !== src)) + setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n }) + onUpdated() } + async function handleCalibrate(srcName) { + // Save this source's current config before opening modal + const cfg = srcCfg[srcName] || {} + await api.upsertStackSource(stack.name, srcName, { + 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, + }) + setCalibratingSource(srcName) + } + + async function applyCalibration(srcName, offset) { + const cfg = srcCfg[srcName] || {} + await api.upsertStackSource(stack.name, srcName, { + field_map: cfg.field_map || {}, + amount_sign: cfg.sign ?? 1, + balance_offset: offset, + amount_field: cfg.amount_field || null, + date_field: cfg.date_field || null, + }) + setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) + setCalibratingSource(null) + onUpdated() + } + + // ── View ── async function generateView() { - setViewResult(null); setCurrentBalance(null); setBalanceError('') + setViewResult(null); setNetBalance(null); setBalanceError('') try { const r = await api.generateStackView(stack.name) setViewResult(r) @@ -244,13 +320,12 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { setBalanceError('') try { const r = await api.getStackBalance(stack.name) - if (r.success) setCurrentBalance(r.balance) + if (r.success) setNetBalance(r.balance) else setBalanceError(r.error) } catch (e) { setBalanceError(e.message) } } const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) - const fieldNames = fields.map(f => f.name) return (
@@ -275,12 +350,65 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { {/* Sources */}
-

Sources

-
- {members.map(m => ( - - ))} +

Sources

+

Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.

+
+ {members.map(m => { + const cfg = srcCfg[m.source_name] || {} + const sf = srcFields[m.source_name] || [] + const canCalibrate = !!cfg.amount_field && !!cfg.date_field + return ( +
+
+ {m.source_name} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }} + className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" /> + +
+
+
+
+ ) + })} {members.length === 0 &&

No sources added yet.

}
{availableSources.length > 0 && ( @@ -296,24 +424,85 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { )}
- {/* Output fields */} + {/* Output columns mapping grid */}
-

Output fields

- {fields.length > 0 && ( -
- {fields.map(f => ( -
- {f.name} - {f.type} - -
- ))} +

Output columns

+

+ Each row is a column in the combined view. Each source column shows which field from that source maps to it. + The first numeric field drives the running balance; the first date field drives the ordering. + Both source_balance (per-source) and net_balance (combined) are always included in the generated view. + Drag rows to reorder. +

+ + {members.length === 0 ? ( +

Add sources above first.

+ ) : ( +
+ + + + + + + {members.map(m => ( + + ))} + + + + + {fields.map((f, idx) => { + const isAmount = f.name === amountCanonical + const isDate = f.name === dateCanonical + return ( + handleDragStart(e, idx)} + onDragOver={e => handleDragOver(e, idx)} + onDrop={e => handleDrop(e, idx)} + onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }} + className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}> + + + + {members.map(m => ( + + ))} + + + ) + })} + {fields.length === 0 && ( + + )} + +
ColumnType{m.source_name}
+ {f.name} + {isAmount && amount} + {isDate && date} + {f.type} +
+ +
+
+ +
No columns defined yet — add one below.
)} - {fields.length === 0 &&

No output fields defined yet.

} -
+ + {/* Add field */} +
setNewField(f => ({ ...f, name: e.target.value }))} + placeholder="column name" value={newField.name} + onChange={e => setNewField(f => ({ ...f, name: e.target.value }))} onKeyDown={e => e.key === 'Enter' && addField()} />
+ + {mappingsDirty && ( + + )}
- {/* Running balance config */} -
-

Running balance

-

Select which output fields drive the running balance. Define output fields above first.

-
-
- - -
-
- - -
-
- -
- - {/* Generate view + current balance */} + {/* Generate view + balance */}

View

@@ -366,17 +534,15 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
- - {currentBalance !== null && ( + {netBalance !== null && (
- Current running balance + Current net balance - {Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })} + {Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
)} {balanceError &&

{balanceError}

} - {viewResult && (
{viewResult.success @@ -384,12 +550,20 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { :

{viewResult.error}

} {viewResult.sql && ( -
{viewResult.sql}
+
{viewResult.sql}
)}
)}
+ {calibratingSource && ( + setCalibratingSource(null)} + onApply={offset => applyCalibration(calibratingSource, offset)} + /> + )}
) } @@ -422,7 +596,7 @@ export default function Stacks({ sources }) { if (!newName) return setError('') try { - await api.createStack({ name: newName, fields: [], balance_offset: 0 }) + await api.createStack({ name: newName, fields: [] }) setNewName(''); setCreating(false) await load() loadDetail(newName) @@ -437,66 +611,51 @@ export default function Stacks({ sources }) { } return ( -
- {/* Stack list */} -
-
-

Stacks

- -
- - {creating && ( -
- + {/* Stack list — horizontal row of cards */} +
+

Stacks

+ {stacks.map(s => ( +
loadDetail(s.name)} + className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}> + {s.label || s.name} + {s.source_count}s + +
+ ))} + {creating ? ( +
+ setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} /> + + {error &&

{error}

} -
- - -
- )} - -
- {stacks.map(s => ( -
loadDetail(s.name)} - className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-sm group ${selected === s.name ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50'}`}> -
-
{s.label || s.name}
-
{s.source_count} source{s.source_count !== 1 ? 's' : ''}
-
- -
- ))} - {stacks.length === 0 && !creating && ( -

No stacks yet.

- )} -
-
- - {/* Detail panel */} -
- {stackDetail ? ( - <> -

- {stackDetail.label || stackDetail.name} - {stackDetail.label && {stackDetail.name}} -

- { load(); loadDetail(stackDetail.name) }} - onDeleted={() => { setSelected(null); setStackDetail(null); load() }} - /> - ) : ( -

Select a stack or create one.

+ )}
+ + {/* Full-width config panel */} + {stackDetail ? ( + <> +

+ {stackDetail.label || stackDetail.name} + {stackDetail.label && {stackDetail.name}} +

+ { load(); loadDetail(stackDetail.name) }} + /> + + ) : ( +

Select a stack or create one.

+ )}
) }