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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-18 21:17:12 -04:00
parent f63a0ec0e5
commit f941c5ae4a
3 changed files with 516 additions and 326 deletions

View File

@ -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) {

View File

@ -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;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Stack not found');
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');
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
SELECT * INTO v_src
FROM dataflow.stack_sources
WHERE stack_name = p_stack_name AND source_name != p_source_name;
WHERE stack_name = p_stack_name AND source_name = p_source_name;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Source not in stack');
END IF;
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;
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,8 +235,9 @@ $$ 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 $$
@ -240,13 +245,17 @@ DECLARE
v_stack dataflow.stacks%ROWTYPE;
v_src dataflow.stack_sources%ROWTYPE;
v_field JSONB;
v_parts TEXT[] := '{}';
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_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(
' FROM dataflow.records rec WHERE rec.source_name = %L AND rec.transformed IS NOT NULL',
v_src.source_name
', 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_parts := v_parts || v_select;
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);
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;

View File

@ -24,14 +24,14 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-lg shadow-xl w-96 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-semibold text-gray-700">Calibrate balance</span>
<span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
<p className="text-xs text-gray-500 mb-3">
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.
</p>
<div className="space-y-3">
<div>
@ -49,9 +49,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
{loading ? 'Calculating…' : 'Calculate'}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{result && !result.success && (
<p className="text-xs text-red-500">{result.error}</p>
)}
{result && !result.success && <p className="text-xs text-red-500">{result.error}</p>}
{result && result.success && (
<div className="bg-gray-50 rounded p-3 text-xs space-y-1">
<div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
@ -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)} />
</div>
<button onClick={() => onApply(parseFloat(applyOffset))}
disabled={applyOffset === ''}
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === ''}
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50">
Apply offset
</button>
@ -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 (
<div className="border border-gray-200 rounded p-3 text-xs">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-700">{member.source_name}</span>
<div className="flex items-center gap-2">
{dirty && (
<button onClick={save} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded hover:bg-blue-700">Save</button>
)}
<button onClick={() => onRemove(member.source_name)} className="text-red-400 hover:text-red-600">Remove</button>
</div>
</div>
{/* Amount sign + balance offset */}
<div className="flex items-center gap-4 mb-3 pb-2 border-b border-gray-100">
<div className="flex items-center gap-2">
<label className="text-gray-500">Amount sign</label>
<select value={sign} onChange={e => handleSign(parseInt(e.target.value))}
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-gray-500">Balance offset</label>
<input type="number" step="0.01" value={offset}
onChange={e => 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" />
<button onClick={() => setCalibrating(true)}
disabled={!stack.amount_field || !stack.date_field}
title={(!stack.amount_field || !stack.date_field) ? 'Set amount and date fields in Running Balance first' : 'Calibrate balance'}
className="text-gray-400 hover:text-blue-500 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">Calibrate</button>
</div>
</div>
{/* Field mappings */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-gray-400 font-medium">Stack field</span>
<span className="text-gray-400 font-medium">Source field</span>
{stackFields.map(f => (
<>
<span key={f.name + '_l'} className="text-gray-600 self-center">{f.name}</span>
<select key={f.name + '_s'} value={map[f.name] || ''}
onChange={e => setMapping(f.name, e.target.value)}
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> same name </option>
{srcFields.map(sf => <option key={sf} value={sf}>{sf}</option>)}
</select>
</>
))}
</div>
{calibrating && (
<CalibrateModal stack={stack} sourceName={member.source_name}
onClose={() => setCalibrating(false)} onApply={applyCalibration} />
)}
</div>
)
}
// 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 (
<div className="space-y-5">
@ -275,12 +350,65 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
{/* Sources */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Sources</h3>
<div className="space-y-3 mb-3">
{members.map(m => (
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
onSave={saveSource} onRemove={removeSource} />
))}
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
<p className="text-xs text-gray-400 mb-3">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.</p>
<div className="space-y-2 mb-3">
{members.map(m => {
const cfg = srcCfg[m.source_name] || {}
const sf = srcFields[m.source_name] || []
const canCalibrate = !!cfg.amount_field && !!cfg.date_field
return (
<div key={m.source_name} className="border border-gray-100 rounded px-3 py-2 text-xs space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div>
<label className="text-gray-400 block mb-0.5">Amount field</label>
<select value={cfg.amount_field || ''}
onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Sign</label>
<select value={cfg.sign ?? 1}
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Date field</label>
<select value={cfg.date_field || ''}
onChange={e => handleSrcDateField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 block mb-0.5">Balance offset</label>
<div className="flex items-center gap-1">
<input type="number" step="0.01" value={cfg.offset ?? 0}
onChange={e => { 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" />
<button onClick={() => handleCalibrate(m.source_name)}
disabled={!canCalibrate}
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
className="text-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
Calibrate
</button>
</div>
</div>
</div>
</div>
)
})}
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
</div>
{availableSources.length > 0 && (
@ -296,24 +424,85 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
)}
</div>
{/* Output fields */}
{/* Output columns mapping grid */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Output fields</h3>
{fields.length > 0 && (
<div className="space-y-1 mb-3">
{fields.map(f => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700 w-32">{f.name}</span>
<span className="text-gray-400">{f.type}</span>
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto"></button>
</div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
<p className="text-xs text-gray-400 mb-3">
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering.
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
Drag rows to reorder.
</p>
{members.length === 0 ? (
<p className="text-xs text-gray-400 mb-3">Add sources above first.</p>
) : (
<div className="overflow-x-auto mb-3">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="w-5 pb-2"></th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
{members.map(m => (
<th key={m.source_name} className="text-left text-gray-400 font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
))}
<th className="w-5 pb-2"></th>
</tr>
</thead>
<tbody>
{fields.map((f, idx) => {
const isAmount = f.name === amountCanonical
const isDate = f.name === dateCanonical
return (
<tr key={f.name}
draggable
onDragStart={e => 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' : ''}`}>
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
{f.name}
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
</td>
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td>
{members.map(m => (
<td key={m.source_name} className="py-1.5 pr-3">
<div className="flex items-center gap-1">
<select
value={getMappingValue(m.source_name, f.name)}
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
className="border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1">
<option value=""> same name </option>
{(srcFields[m.source_name] || []).map(sf => (
<option key={sf} value={sf}>{sf}</option>
))}
</select>
</div>
</td>
))}
<td className="py-1.5">
<button onClick={() => removeField(f.name)} className="text-red-300 hover:text-red-500"></button>
</td>
</tr>
)
})}
{fields.length === 0 && (
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet add one below.</td></tr>
)}
</tbody>
</table>
</div>
)}
{fields.length === 0 && <p className="text-xs text-gray-400 mb-3">No output fields defined yet.</p>}
<div className="flex gap-2">
{/* Add field */}
<div className="flex gap-2 mb-3">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
placeholder="field name" value={newField.name} onChange={e => 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()} />
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
@ -321,37 +510,16 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
</select>
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
</div>
</div>
{/* Running balance config */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Running balance</h3>
<p className="text-xs text-gray-400 mb-3">Select which output fields drive the running balance. Define output fields above first.</p>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Amount field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={amountField} onChange={e => setAmountField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Date field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={dateField} onChange={e => setDateField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<button onClick={saveBalance} disabled={saving}
{mappingsDirty && (
<button onClick={saveMappings} disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
{saving ? 'Saving…' : 'Save mappings'}
</button>
)}
</div>
{/* Generate view + current balance */}
{/* Generate view + balance */}
<div className="bg-white border border-gray-200 rounded p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3>
@ -366,17 +534,15 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
</button>
</div>
</div>
{currentBalance !== null && (
{netBalance !== null && (
<div className="mb-3 flex items-center gap-3">
<span className="text-xs text-gray-500">Current running balance</span>
<span className="text-xs text-gray-500">Current net balance</span>
<span className="text-lg font-mono font-semibold text-gray-800">
{Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
)}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
{viewResult && (
<div className="text-xs">
{viewResult.success
@ -384,12 +550,20 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
: <p className="text-red-500">{viewResult.error}</p>
}
{viewResult.sql && (
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 text-xs leading-relaxed">{viewResult.sql}</pre>
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 leading-relaxed">{viewResult.sql}</pre>
)}
</div>
)}
</div>
{calibratingSource && (
<CalibrateModal
stack={stack}
sourceName={calibratingSource}
onClose={() => setCalibratingSource(null)}
onApply={offset => applyCalibration(calibratingSource, offset)}
/>
)}
</div>
)
}
@ -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,51 +611,38 @@ export default function Stacks({ sources }) {
}
return (
<div className="p-6 flex gap-6 max-w-5xl">
{/* Stack list */}
<div className="w-52 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h1 className="text-sm font-semibold text-gray-800">Stacks</h1>
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700">+ New</button>
</div>
{creating && (
<div className="mb-3">
<input autoFocus className="w-full border border-blue-400 rounded px-2 py-1 text-sm focus:outline-none mb-1"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-1">
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-2 py-0.5">Cancel</button>
</div>
</div>
)}
<div className="space-y-0.5">
<div className="p-6">
{/* Stack list — horizontal row of cards */}
<div className="flex items-center gap-2 mb-5 flex-wrap">
<h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1>
{stacks.map(s => (
<div key={s.name}
onClick={() => 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'}`}>
<div className="min-w-0">
<div className="truncate font-medium">{s.label || s.name}</div>
<div className="text-xs text-gray-400">{s.source_count} source{s.source_count !== 1 ? 's' : ''}</div>
</div>
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'}`}>
<span className="font-medium">{s.label || s.name}</span>
<span className="text-gray-400">{s.source_count}s</span>
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 text-xs ml-1 flex-shrink-0"></button>
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none"></button>
</div>
))}
{stacks.length === 0 && !creating && (
<p className="text-xs text-gray-400 px-2">No stacks yet.</p>
{creating ? (
<div className="flex items-center gap-1">
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-1"></button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
) : (
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button>
)}
</div>
</div>
{/* Detail panel */}
<div className="flex-1 min-w-0">
{/* Full-width config panel */}
{stackDetail ? (
<>
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<h2 className="text-base font-semibold text-gray-800 mb-4">
{stackDetail.label || stackDetail.name}
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
</h2>
@ -490,13 +651,11 @@ export default function Stacks({ sources }) {
stack={stackDetail}
sources={sources}
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
onDeleted={() => { setSelected(null); setStackDetail(null); load() }}
/>
</>
) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p>
)}
</div>
</div>
)
}