/* money-trace · dashboard part 2 — extended perspective tabs Balances · People · Cards & credit · Rhythm · Ledger explorer · Data quality Registers into the controller via window.MTdash.register(). */ (function () { 'use strict'; const D = window.MTdash, C = window.Charts, MT = window.MT; const { TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi } = D; const { isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal } = D; const $ = s => document.querySelector(s); const CLPk = MT.CLPk, CLP = MT.CLP; const ms = ymd => +new Date(ymd + 'T00:00:00'); // palette for many-series const SERIES_COLORS = ['#2ee6a6', '#6fd0ff', '#b98cff', '#ffc24b', '#ff8f6b', '#5fd0a0', '#8c9eff', '#ff5f73', '#d0d6e0', '#46c6ba']; // ============================================================ BALANCES & RUNWAY function renderBalances(host) { const tx = TX(); // group transactions that carry a balance, by account const acctMap = {}; for (const t of tx) { if (t.balance == null || t.doc_type === 'tarjeta_credito') continue; const key = t.bank + '·' + (t.last4 || t.doc_type) + '·' + t.doc_type; (acctMap[key] = acctMap[key] || { bank: t.bank, last4: t.last4, doc_type: t.doc_type, pts: [] }); acctMap[key].pts.push({ t: ms(t.date), v: t.balance, tx: t }); } // keep accounts with >=4 points, sort by point count let accts = Object.entries(acctMap).map(([k, a]) => ({ key: k, ...a })) .filter(a => a.pts.length >= 4) .sort((a, b) => b.pts.length - a.pts.length); accts.forEach((a, i) => { a.color = SERIES_COLORS[i % SERIES_COLORS.length]; a.pts.sort((p, q) => p.t - q.t); a.last = a.pts[a.pts.length - 1].v; a.min = Math.min(...a.pts.map(p => p.v)); }); // default selection: top 4 if (!host._sel) host._sel = new Set(accts.slice(0, 4).map(a => a.key)); const sel = host._sel; const lowest = accts.slice().sort((a, b) => a.min - b.min)[0]; const liquid = accts.filter(a => a.doc_type !== 'linea_credito').reduce((s, a) => s + a.last, 0); host.innerHTML = `
${kpi('Accounts with balances', accts.length, 'of 17 reconstructed accounts', col.income())} ${kpi('Latest liquid total', fmtBig(liquid), 'sum of last-known cash balances', col.income())} ${kpi('Lowest point', fmtBig(lowest ? lowest.min : 0), lowest ? `${esc(lowest.bank)} ··${lowest.last4 || ''}` : '', lowest && lowest.min < 0 ? col.spend() : col.fee())}

Balance over timerunning balance, where statements report it

i
Credit cards don't report a running balance in these statements, so they're excluded here. Coverage varies by account — lines connect only the points each statement actually logged.
`; const chips = $('#bal-chips'); accts.forEach(a => { const c = document.createElement('div'); c.className = 'acct-chip' + (sel.has(a.key) ? ' on' : ''); c.innerHTML = `${esc(a.bank)}${MT.acctShort(a.doc_type)}${a.last4 ? ' ··' + a.last4 : ''}`; c.addEventListener('click', () => { sel.has(a.key) ? sel.delete(a.key) : sel.add(a.key); renderBalances(host); }); chips.appendChild(c); }); const series = accts.filter(a => sel.has(a.key)).map(a => ({ label: a.bank + ' ' + MT.acctShort(a.doc_type) + (a.last4 ? ' ··' + a.last4 : ''), color: a.color, points: a.pts })); C.multiLine($('#bal-chart'), series, { height: 320 }); } // ============================================================ PEOPLE function renderPeople(host) { const tx = TX(); // build per-person ledger from income (in), inter_person (in/out) const ppl = {}; function add(name, t, dir) { const k = name; (ppl[k] = ppl[k] || { name, in: 0, out: 0, inTx: [], outTx: [], dates: [] }); if (dir === 'in') { ppl[k].in += t.amount; ppl[k].inTx.push(t); } else { ppl[k].out += t.amount; ppl[k].outTx.push(t); } ppl[k].dates.push(t.date); } for (const t of tx) { if (isIncome(t)) add(MT.normIncome(t.counterparty), t, 'in'); else if (isInterIn(t)) add(MT.normPerson(t.counterparty), t, 'in'); else if (isInterOut(t)) add(MT.normPerson(t.counterparty), t, 'out'); } let people = Object.values(ppl).map(p => { p.net = p.in - p.out; p.gross = p.in + p.out; p.n = p.inTx.length + p.outTx.length; p.dates.sort(); p.first = p.dates[0]; p.last = p.dates[p.dates.length - 1]; return p; }).filter(p => p.name && p.name !== '—').sort((a, b) => b.gross - a.gross); const payers = people.filter(p => p.net > 0).slice(0, 1); const namedIncome = people.reduce((s, p) => s + p.in, 0); host.innerHTML = `
${kpi('People & payers', people.length, 'distinct counterparties with a name', col.person())} ${kpi('Biggest net source', payers.length ? esc(payers[0].name) : '—', payers.length ? '+' + CLPk(payers[0].net) + ' net to you' : '', col.income())} ${kpi('Named-source income', fmtBig(namedIncome), 'total in from identified people', col.income())}

Your money relationshipsmoney in · money out · net

Person / payerTxnsMoney inMoney outNetIn vs outLast seen
`; const tb = $('#ppl-rows'); const maxGross = Math.max(...people.map(p => p.gross), 1); people.forEach(p => { const inPct = p.gross ? p.in / p.gross * 100 : 0; const tr = document.createElement('tr'); tr.innerHTML = `
${esc(p.name)}
${p.n} ${p.in ? CLPk(p.in) : ''} ${p.out ? CLPk(p.out) : ''} ${p.net >= 0 ? '+' : '−'}${CLPk(Math.abs(p.net))} ${p.last ? p.last.slice(0, 7) : ''}`; tr.addEventListener('mousemove', e => window.MTtip.rows(e, p.name, p.net >= 0 ? col.income() : col.spend(), [...p.inTx, ...p.outTx])); tr.addEventListener('mouseleave', () => window.MTtip.hide()); tb.appendChild(tr); }); } // ============================================================ CARDS & CREDIT function renderCards(host) { const tx = TX(); const fees = tx.filter(isFee); const feeTotal = sum(fees); // classify fee descriptions const feeCat = d => { const u = (d || '').toUpperCase(); if (/INTERES|MORA|ROTATIV/.test(u)) return 'Interest'; if (/IMPUESTO|DL 3475|DECRETO/.test(u)) return 'Taxes & stamp'; if (/COBRANZA/.test(u)) return 'Collection charges'; if (/COMIS|MANTENCION|MANTENC|ADMIN|SERVICIO|CARGO/.test(u)) return 'Commissions & maintenance'; return 'Other charges'; }; const byFee = groupSum(fees, t => feeCat(t.description)); const feeArr = sortEntries(byFee); const FEE_C = { 'Interest': '#ff5f73', 'Taxes & stamp': '#ffc24b', 'Collection charges': '#ff8f6b', 'Commissions & maintenance': '#b98cff', 'Other charges': '#7a8699' }; // credit-line activity: draws (debit on cuenta = borrowing) vs repayments const lineTx = tx.filter(t => t.flow_type === 'credit_line'); const cardPay = tx.filter(t => t.flow_type === 'card_payment'); const cardPayTotal = sum(cardPay); const months = MT.months; const feeByM = {}; months.forEach(m => feeByM[m] = sum(fees.filter(t => t.ym === m))); // which cards/lines exist const cardAccts = {}; for (const t of tx) { if (!MT.isCardType(t.doc_type)) continue; const k = t.bank + ' · ' + MT.acctShort(t.doc_type); (cardAccts[k] = cardAccts[k] || { bank: t.bank, type: t.doc_type, fees: 0, spend: 0, n: 0 }); cardAccts[k].n++; if (isFee(t)) cardAccts[k].fees += t.amount; if (isExpense(t)) cardAccts[k].spend += t.amount; } const cardList = Object.entries(cardAccts).sort((a, b) => (b[1].fees + b[1].spend) - (a[1].fees + a[1].spend)); host.innerHTML = `
${kpi('Paid in fees & interest', fmtBig(feeTotal), `${fees.length} charges across all cards & lines`, col.fee())} ${kpi('Card payments made', fmtBig(cardPayTotal), `${cardPay.length} payments to your own cards`, col.internal())} ${kpi('Interest alone', fmtBig(byFee['Interest'] ? byFee['Interest'].value : 0), Math.round((byFee['Interest'] ? byFee['Interest'].value : 0) / feeTotal * 100) + '% of all charges', col.spend())}

What the bank charged youfees & interest by type

Charges by monthCLP / month

Per card & linespend + fees

Card / lineTxnsSpend on itFees chargedFee rate
`; C.hbars($('#cd-fees'), feeArr.map(([label, d]) => ({ label, value: d.value, color: FEE_C[label] || col.mute(), sub: d.txns.length + ' charges · ' + Math.round(d.value / feeTotal * 100) + '%', _txns: d.txns, _c: FEE_C[label] || col.mute() })), { max: feeArr[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) }); C.monthlyBars($('#cd-month'), months, [{ key: 'f', label: 'Fees & interest', color: col.fee(), values: feeByM }], { height: 200 }); const tb = $('#cd-rows'); cardList.forEach(([name, c]) => { const rate = c.spend ? c.fees / c.spend * 100 : null; const tr = document.createElement('tr'); tr.innerHTML = `
${esc(name)}
${c.n} ${c.spend ? CLPk(c.spend) : ''} ${c.fees ? CLPk(c.fees) : ''} ${rate != null ? Math.round(rate) + '%' : '—'}`; tb.appendChild(tr); }); } // ============================================================ RHYTHM / CALENDAR function renderRhythm(host) { const tx = TX(); const spend = tx.filter(t => isExpense(t) || isFee(t)); const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // weekday × (none) totals const wdAmt = [0, 0, 0, 0, 0, 0, 0], wdTx = [[], [], [], [], [], [], []]; for (const t of spend) { const w = new Date(t.date + 'T00:00:00').getDay(); wdAmt[w] += t.amount; wdTx[w].push(t); } // month × day-of-month heatmap for spending const months = MT.months; const grid = {}; months.forEach(m => grid[m] = {}); for (const t of spend) { const day = +t.date.slice(8, 10); (grid[t.ym][day] = grid[t.ym][day] || { v: 0, raw: [] }); grid[t.ym][day].v += t.amount; grid[t.ym][day].raw.push(t); } // busiest weekday const peakWd = wdAmt.indexOf(Math.max(...wdAmt)); const totalSpend = sum(spend); const weekendShare = Math.round((wdAmt[0] + wdAmt[6]) / totalSpend * 100); const WDLONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; host.innerHTML = `
${kpi('Busiest spending day', WDLONG[peakWd], CLPk(wdAmt[peakWd]) + ' total spent', col.spend())} ${kpi('Weekend share', weekendShare + '%', 'of spending lands Sat–Sun', col.fee())} ${kpi('Active days', Object.values(grid).reduce((s, m) => s + Object.keys(m).length, 0), 'days with at least one purchase', col.income())}

Spending by day of monthdarker = more spent

less
more

Which weekday do you spend?total by day of week

`; // heatmap: rows = months, cols = days 1..31 const days = Array.from({ length: 31 }, (_, i) => i + 1); const rows = months.map(m => ({ label: MT.MONTH_LABEL(m), cells: days.map(d => { const cell = grid[m][d]; return { v: cell ? cell.v : 0, raw: cell ? cell.raw : [], title: cell ? MT.MONTH_LABEL(m) + ' ' + d + ' · ' + CLP(cell.v) : '' }; }) })); const heatHost = $('#rh-heat'); heatHost.style.setProperty('--cols', 31); heatHost.querySelectorAll('.hm-row').forEach(r => r.style.setProperty('--cols', 31)); C.heatmap(heatHost, rows, days.map(d => d % 2 ? d : ''), { color: col.spend() }); heatHost.querySelectorAll('.hm-row').forEach(r => r.style.gridTemplateColumns = '64px repeat(31, 1fr)'); // weekday bars C.hbars($('#rh-wd'), WDLONG.map((w, i) => ({ label: w, value: wdAmt[i], color: (i === 0 || i === 6) ? col.fee() : col.spend(), _txns: wdTx[i], _c: (i === 0 || i === 6) ? col.fee() : col.spend() })), { max: Math.max(...wdAmt), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) }); } // ============================================================ LEDGER EXPLORER function renderLedger(host) { const tx = TX().slice(); if (!host._state) host._state = { q: '', flow: 'all', bank: 'all', sort: 'date', dir: -1, limit: 60 }; const st = host._state; const FLOW_C = { income: col.income(), inter_person: col.person(), expense: col.spend(), fee: col.fee(), self_transfer: col.internal(), credit_line: col.internal(), card_payment: col.internal() }; const FLOW_LABEL = { income: 'Income', inter_person: 'Person', expense: 'Spending', fee: 'Fee', self_transfer: 'Self-transfer', credit_line: 'Credit line', card_payment: 'Card payment' }; host.innerHTML = `
DateDescriptionAccount TypeAmount
`; // flow select const flowOpts = ['all', 'income', 'inter_person', 'expense', 'fee', 'self_transfer', 'credit_line', 'card_payment']; $('#lx-flow').innerHTML = flowOpts.map(f => ``).join(''); $('#lx-bank').innerHTML = `` + MT.banks.map(b => ``).join(''); function apply() { let rows = tx; const q = st.q.trim().toLowerCase(); if (q) rows = rows.filter(t => (t.description || '').toLowerCase().includes(q) || (t.counterparty || '').toLowerCase().includes(q) || t.bank.toLowerCase().includes(q)); if (st.flow !== 'all') rows = rows.filter(t => t.flow_type === st.flow); if (st.bank !== 'all') rows = rows.filter(t => t.bank === st.bank); rows.sort((a, b) => { let av, bv; if (st.sort === 'date') { av = a.date; bv = b.date; } else if (st.sort === 'amount') { av = a.amount; bv = b.amount; } else if (st.sort === 'bank') { av = a.bank; bv = b.bank; } else if (st.sort === 'flow') { av = a.flow_type; bv = b.flow_type; } else { av = a.description || ''; bv = b.description || ''; } return (av < bv ? -1 : av > bv ? 1 : 0) * st.dir; }); $('#lx-count').textContent = rows.length + ' of ' + tx.length + ' transactions'; const shown = rows.slice(0, st.limit); $('#lx-rows').innerHTML = shown.map(t => ` ${t.date} ${window.MTlogo ? window.MTlogo.html(t.description || t.counterparty || '', 18, {flowType: t.flow_type}) : ''}${esc(t.description || t.counterparty || '—')} ${esc(t.bank)} ${MT.acctShort(t.doc_type)}${t.last4 ? ' ··' + t.last4 : ''} ${FLOW_LABEL[t.flow_type] || t.flow_type} ${t.direction === 'credit' ? '+' : '−'}${CLP(t.amount)}`).join(''); $('#lx-more').innerHTML = rows.length > st.limit ? `
Show ${Math.min(60, rows.length - st.limit)} more (${rows.length - st.limit} hidden)
` : ''; const more = $('#lx-more').querySelector('.lx-more'); if (more) more.addEventListener('click', () => { st.limit += 60; apply(); }); } $('#lx-q').addEventListener('input', e => { st.q = e.target.value; st.limit = 60; apply(); }); $('#lx-flow').addEventListener('change', e => { st.flow = e.target.value; st.limit = 60; apply(); }); $('#lx-bank').addEventListener('change', e => { st.bank = e.target.value; st.limit = 60; apply(); }); host.querySelectorAll('.lx-table th[data-s]').forEach(th => th.addEventListener('click', () => { const s = th.dataset.s; if (st.sort === s) st.dir *= -1; else { st.sort = s; st.dir = s === 'date' || s === 'amount' ? -1 : 1; } apply(); })); apply(); } // ============================================================ DATA QUALITY function renderQuality(host) { const tx = TX(); const raw = MT.raw; const months = MT.months; // statement coverage: account -> set of months covered (from period_start..period_end) const acctCov = {}; for (const s of raw.statements) { const key = MT.bankName(s.bank) + ' · ' + MT.acctShort(s.doc_type) + (s.account_last4 ? ' ··' + s.account_last4 : ''); (acctCov[key] = acctCov[key] || { months: new Set(), docs: 0, bank: MT.bankName(s.bank) }); acctCov[key].docs++; if (s.period_start && s.period_end) { let c = new Date(s.period_start.slice(0, 7) + '-01T00:00:00'); const end = new Date(s.period_end.slice(0, 7) + '-01T00:00:00'); while (c <= end) { acctCov[key].months.add(c.toISOString().slice(0, 7)); c.setMonth(c.getMonth() + 1); } } } const accts = Object.entries(acctCov).sort((a, b) => b[1].docs - a[1].docs); // metrics const withBal = tx.filter(t => t.balance != null).length; const withPeriod = raw.statements.filter(s => s.period_start && s.period_end).length; const classified = tx.filter(t => t.flow_type !== 'expense' || MT.spendCategory(t.description) !== 'Other purchases').length; const otherSpend = tx.filter(t => t.flow_type === 'expense' && MT.spendCategory(t.description) === 'Other purchases').length; const namedCp = tx.filter(t => t.counterparty).length; const pct = (a, b) => Math.round(a / b * 100); function metric(v, total, label, c) { return `
${pct(v, total)}%
${label}
`; } host.innerHTML = `
${metric(withPeriod, raw.statements.length, 'Statements with a clear period', col.income())} ${metric(withBal, tx.length, 'Transactions with a running balance', col.fee())} ${metric(namedCp, tx.length, 'Transactions with a named counterparty', col.person())} ${metric(tx.length - otherSpend, tx.length, 'Transactions confidently categorized', col.income())}

Statement coverage${accts.length} accounts × ${months.length} months

Each filled cell = a statement covers that month. Gaps = months with no statement on file.
!
${otherSpend} purchases (${pct(otherSpend, tx.length)}%) couldn't be matched to a spending category and sit in "Other purchases" — the raw statements carry no merchant field, so these are inferred from free-text descriptions.
i
Credit-card statements report transactions but not running balances, so the Balances tab covers cash and credit-line accounts only.
`; // coverage grid const grid = $('#cov-grid'); grid.style.setProperty('--cols', months.length); const head = document.createElement('div'); head.className = 'cov-row cov-head'; head.style.setProperty('--cols', months.length); head.innerHTML = `
` + months.map(m => `
${MT.MONTH_LABEL(m)}
`).join(''); grid.appendChild(head); accts.forEach(([name, a]) => { const row = document.createElement('div'); row.className = 'cov-row'; row.style.setProperty('--cols', months.length); let html = `
${esc(name)}
${a.docs} stmts · ${a.months.size} mo
`; months.forEach(m => { html += `
`; }); row.innerHTML = html; grid.appendChild(row); }); } // ============================================================ PLATFORMS const RAILS = [ ['MercadoPago', /MERCADO ?PAGO|MERPAGO|MERPAG|MP —|MP\*/i, '#6fd0ff'], ['MACH', /\bMACH\b/i, '#2ee6a6'], ['WebPay', /WEBPAY|WEB PAY/i, '#b98cff'], ['PayU', /\bPAYU\b/i, '#ffc24b'], ['Rappi', /RAPPI/i, '#ff8f6b'], ['Uber', /\bUBER\b|UBER ?EATS/i, '#5fd0a0'], ['SumUp', /SUMUP/i, '#8c9eff'], ['Payscan', /PAYSCAN/i, '#ff5f73'], ['Tuu', /\bTUU\b|TUU\*/i, '#d0d6e0'], ['Fintoc', /FINTOC/i, '#46c6ba'], ['Servipag', /SERVIPAG/i, '#ffd45a'], ['Sencillito', /SENCILLITO/i, '#c9a6ff'], ['Khipu', /KHIPU/i, '#7CFFB2'], ['Kushki', /KUSHKI/i, '#FF8FA3'], ['OneClick', /ONECLICK|ONE CLICK/i, '#6fd0ff'], ['Compraqui', /COMPRAQUI/i, '#ff8f6b'] ]; function railOf(t) { if (t.platform) { for (const [n, re] of RAILS) if (re.test(t.platform)) return n; } for (const [n, re] of RAILS) if (re.test(t.description || '')) return n; return null; } const railColor = name => (RAILS.find(r => r[0] === name) || [, , col.mute()])[2]; function renderPlatforms(host) { const tx = TX(); const railTx = {}; for (const t of tx) { const r = railOf(t); if (!r) continue; (railTx[r] = railTx[r] || []).push(t); } let rails = Object.entries(railTx).map(([name, txns]) => { const out = txns.filter(t => t.direction === 'debit'), inn = txns.filter(t => t.direction === 'credit'); return { name, txns, n: txns.length, amt: sum(txns), out: sum(out), in: sum(inn), color: railColor(name) }; }).sort((a, b) => b.amt - a.amt); const totalAmt = rails.reduce((s, r) => s + r.amt, 0); const totalN = rails.reduce((s, r) => s + r.n, 0); const byCount = rails.slice().sort((a, b) => b.n - a.n); const outTotal = rails.reduce((s, r) => s + r.out, 0), inTotal = rails.reduce((s, r) => s + r.in, 0); host.innerHTML = `
${kpi('Payment rails seen', rails.length, `across ${totalN} transactions`, col.person())} ${kpi('Most used', byCount.length ? esc(byCount[0].name) : '—', byCount.length ? byCount[0].n + ' transactions' : '', byCount.length ? byCount[0].color : col.mute())} ${kpi('Routed through platforms', fmtBig(totalAmt), '~' + Math.round(totalN / tx.length * 100) + '% of all transactions', col.income())}

Platforms by valuemoney moved per rail

By transaction counthow often used

i
Rails are detected from the platform field and transaction descriptions, so this covers the ~${Math.round(totalN / tx.length * 100)}% of transactions that name a processor. Servipag and MACH move large sums in a few bill-payments, while MercadoPago and PayU appear in many small card purchases.
`; C.hbars($('#pf-amt'), rails.map(r => ({ label: r.name, value: r.amt, color: r.color, sub: r.n + ' txns' + (r.in && r.out ? ' · in+out' : r.in ? ' · money in' : ' · purchases'), _txns: r.txns, _c: r.color })), { max: rails[0] ? rails[0].amt : 1, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) }); C.hbars($('#pf-cnt'), byCount.map(r => ({ label: r.name, value: r.n, color: r.color, sub: CLPk(r.amt), _txns: r.txns, _c: r.color })), { max: byCount[0] ? byCount[0].n : 1, money: false, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) }); } // register all D.register('balances', renderBalances); D.register('people', renderPeople); D.register('cards', renderCards); D.register('rhythm', renderRhythm); D.register('ledger', renderLedger); D.register('quality', renderQuality); D.register('platforms', renderPlatforms); })();