/* 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 / payer Txns Money in Money out Net In vs out Last 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 / line Txns Spend on it Fees charged Fee 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
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 = `
Date Description Account
Type Amount
`;
// flow select
const flowOpts = ['all', 'income', 'inter_person', 'expense', 'fee', 'self_transfer', 'credit_line', 'card_payment'];
$('#lx-flow').innerHTML = flowOpts.map(f => `${f === 'all' ? 'All types' : FLOW_LABEL[f]} `).join('');
$('#lx-bank').innerHTML = `All banks ` + MT.banks.map(b => `${esc(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}
${esc(MT.cleanDesc(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);
})();