/* money-trace · dashboard controller + 5 perspective tabs */
(function () {
'use strict';
const $ = s => document.querySelector(s);
const C = window.Charts;
const col = {
income: () => cssv('--c-income'), spend: () => cssv('--c-spend'),
person: () => cssv('--c-person'), fee: () => cssv('--c-fee'),
internal: () => cssv('--c-internal'), ink: () => cssv('--ink'), mute: () => cssv('--ink-mute')
};
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const esc = C.esc;
// ---------- tooltip ----------
const tip = $('#tip');
function place(e) {
const w = tip.offsetWidth, h = tip.offsetHeight, pad = 16;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > innerWidth - 8) x = e.clientX - w - pad;
if (y + h > innerHeight - 8) y = e.clientY - h - pad;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
window.MTtip = {
raw(e, title, sub, c) {
tip.innerHTML = `
${esc(title)}
${esc(sub)}
`;
tip.classList.add('show'); place(e);
},
rows(e, title, c, txns) {
const top = txns.slice().sort((a, b) => b.amount - a.amount).slice(0, 6);
let html = `${esc(title)}
${window.MT.CLP(txns.reduce((a, t) => a + t.amount, 0))}
${txns.length} transactions
`;
for (const t of top) html += `
${t.date.slice(5).replace('-', '/')}${esc(window.MT.cleanDesc(t.description) || t.counterparty || '—')}${window.MT.CLP(t.amount)}
`;
html += '
';
if (txns.length > 6) html += `+${txns.length - 6} more
`;
tip.innerHTML = html; tip.classList.add('show'); place(e);
},
hide() { tip.classList.remove('show'); }
};
// ---------- aggregation ----------
const TX = () => window.MT.tx;
const sum = arr => arr.reduce((a, t) => a + t.amount, 0);
const groupSum = (arr, keyFn) => {
const m = {};
for (const t of arr) { const k = keyFn(t) || '—'; (m[k] = m[k] || { value: 0, txns: [] }); m[k].value += t.amount; m[k].txns.push(t); }
return m;
};
const sortEntries = m => Object.entries(m).sort((a, b) => b[1].value - a[1].value);
const isExpense = t => t.flow_type === 'expense';
const isFee = t => t.flow_type === 'fee';
const isIncome = t => t.flow_type === 'income';
const isInterIn = t => t.flow_type === 'inter_person' && t.direction === 'credit';
const isInterOut = t => t.flow_type === 'inter_person' && t.direction === 'debit';
const isInternal = t => ['self_transfer', 'credit_line', 'card_payment'].includes(t.flow_type);
// ---------- shared totals ----------
let TOT;
function computeTotals() {
const tx = TX();
const realIn = sum(tx.filter(t => isIncome(t) || isInterIn(t)));
const realOut = sum(tx.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
const internal = sum(tx.filter(isInternal));
TOT = { realIn, realOut, internal, net: realIn - realOut, gross: realIn + realOut + internal };
}
const fmtBig = v => {
const sign = v < 0 ? '−' : ''; const a = Math.abs(v);
if (a >= 1e6) return sign + '$' + (a / 1e6).toLocaleString('es-CL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + 'M';
if (a >= 1e3) return sign + '$' + Math.round(a / 1e3).toLocaleString('es-CL') + 'k';
return sign + window.MT.CLP(a);
};
const kpi = (lab, val, sub, accent) =>
`${lab}
${val}
${sub ? `
${sub}
` : ''}
`;
// ============================================================ TAB 1: OVERVIEW
function renderOverview(host) {
const tx = TX();
const months = window.MT.months;
const introEl = document.getElementById('ov-intro');
if (introEl) introEl.innerHTML = `Across ${months.length} months, ${window.MT.CLPk(TOT.realIn)} came in and ${window.MT.CLPk(TOT.realOut)} went out — but your statements logged ${window.MT.CLPk(TOT.gross)} of total movement. Here's the real story versus the noise.`;
const inByM = {}, outByM = {}, netByM = {};
months.forEach(m => {
inByM[m] = sum(tx.filter(t => t.ym === m && (isIncome(t) || isInterIn(t))));
outByM[m] = sum(tx.filter(t => t.ym === m && (isExpense(t) || isFee(t) || isInterOut(t))));
netByM[m] = inByM[m] - outByM[m];
});
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
host.innerHTML = `
${kpi('Real income', fmtBig(TOT.realIn), '93 deposits & transfers in', col.income())}
${kpi('Real spending', fmtBig(TOT.realOut), 'purchases · fees · people', col.spend())}
${kpi('Net real', (TOT.net < 0 ? '−' : '') + fmtBig(Math.abs(TOT.net)), 'income minus spending', col.ink())}
Income vs spending by monthmonthly, CLP
Money inMoney out
Real vs internal
Real money ${window.MT.CLPk(TOT.realIn + TOT.realOut)}
Internal shuffle ${window.MT.CLPk(TOT.internal)}
↻
For every $1 of real money, $${mult.toFixed(1)} moved between your own accounts. Most of the statement volume is noise.
Cumulative net positionrunning income − spending
`;
C.monthlyBars($('#ov-bars'), months, [
{ key: 'in', label: 'Money in', color: col.income(), values: inByM },
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
], { height: 230 });
C.donut($('#ov-donut'), [
{ label: 'Real money', value: TOT.realIn + TOT.realOut, color: col.income() },
{ label: 'Internal shuffle', value: TOT.internal, color: col.internal() }
], { size: 176, centerTop: 'x' + mult.toFixed(1), centerBot: 'multiplier' });
C.lineChart($('#ov-line'), months, netByM, { height: 200, color: col.income() });
}
// ============================================================ TAB 2: SPENDING
function renderSpending(host) {
const tx = TX();
const spend = tx.filter(t => isExpense(t) || isFee(t));
const total = sum(spend);
const byCat = groupSum(spend, t => isFee(t) ? 'Fees & interest' : window.MT.spendCategory(t.description));
const cats = sortEntries(byCat);
const palette = ['#ff5f73', '#ff7a6b', '#ff9460', '#ffae57', '#ffc24b', '#d98cff', '#8c9eff', '#6fd0ff', '#5fd0a0', '#7a8699'];
const catColor = i => i < palette.length ? palette[i] : col.mute();
const months = window.MT.months;
const spendByM = {}; months.forEach(m => spendByM[m] = sum(spend.filter(t => t.ym === m)));
// top merchants
const byMerch = groupSum(tx.filter(isExpense), t => window.MT.cleanDesc(t.description) || 'Unknown');
const merch = sortEntries(byMerch).slice(0, 10);
const biggestCat = cats[0];
host.innerHTML = `
${kpi('Total spending', fmtBig(total), `${spend.length} purchases & fees`, col.spend())}
${kpi('Biggest category', biggestCat[0], `${window.MT.CLPk(biggestCat[1].value)} · ${Math.round(biggestCat[1].value / total * 100)}% of spend`, palette[0])}
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.fee())}
Where it actually goesby category
Top merchantssingle payees
Spending by monthCLP / month
Categories & merchantsclick to expand
`;
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
label, value: d.value, color: catColor(i), sub: d.txns.length + ' txns · ' + Math.round(d.value / total * 100) + '%',
_txns: d.txns, _c: catColor(i)
})), { max: cats[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
const ml = $('#sp-merch');
merch.forEach(([name, d], i) => {
const cat = window.MT.spendCategory(d.txns[0].description);
const row = document.createElement('div'); row.className = 'mrow';
row.innerHTML = `${i + 1}${esc(name)}${esc(cat)}${d.txns.length}×${window.MT.CLPk(d.value)}`;
row.addEventListener('mousemove', e => window.MTtip.rows(e, name, col.spend(), d.txns));
row.addEventListener('mouseleave', () => window.MTtip.hide());
ml.appendChild(row);
});
C.monthlyBars($('#sp-month'), months, [{ key: 'sp', label: 'Spending', color: col.spend(), values: spendByM }], { height: 200 });
renderCategoryBreakdown($('#sp-breakdown'), cats, catColor, total);
}
function renderCategoryBreakdown(host, cats, catColor, total) {
const MT = window.MT;
host.innerHTML = '';
cats.forEach(([catName, catData], i) => {
const color = catColor(i);
const byDesc = groupSum(catData.txns, t => MT.cleanDesc(t.description) || t.description || '—');
const descs = sortEntries(byDesc);
const pct = Math.round(catData.value / total * 100);
const section = document.createElement('div');
section.className = 'bk-section';
const rowsHtml = descs.slice(0, 30).map(([desc, d]) => {
const cpct = Math.round(d.value / catData.value * 100);
return `
${esc(desc)}
${d.txns.length}×
${cpct}%
${MT.CLPk(d.value)}
`;
}).join('') + (descs.length > 30 ? `+${descs.length - 30} more descriptions
` : '');
section.innerHTML = `
${rowsHtml}
`;
const header = section.querySelector('.bk-header');
const rows = section.querySelector('.bk-rows');
const toggle = section.querySelector('.bk-toggle');
header.addEventListener('click', () => {
const open = !rows.hidden;
rows.hidden = open;
toggle.textContent = open ? '▶' : '▼';
});
// expand biggest category by default
if (i === 0) { rows.hidden = false; toggle.textContent = '▼'; }
host.appendChild(section);
});
}
// ============================================================ TAB 3: INCOME
function renderIncome(host) {
const tx = TX();
const inc = tx.filter(t => isIncome(t) || isInterIn(t));
const total = sum(inc);
const bySrc = groupSum(inc, t => isIncome(t) ? window.MT.normIncome(t.counterparty) : window.MT.normPerson(t.counterparty));
const srcs = sortEntries(bySrc);
const palette = ['#2ee6a6', '#3ad6b0', '#46c6ba', '#52b6c4', '#5ea6ce', '#b98cff', '#8c9eff', '#7a8699'];
const months = window.MT.months;
const incByM = {}; months.forEach(m => incByM[m] = sum(inc.filter(t => t.ym === m)));
const top3 = srcs.slice(0, 3).reduce((a, [, d]) => a + d.value, 0);
const conc = Math.round(top3 / total * 100);
host.innerHTML = `
${kpi('Total income', fmtBig(total), `${inc.length} deposits & transfers in`, col.income())}
${kpi('Top 3 sources', conc + '%', 'of all income comes from 3 payers', col.income())}
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.person())}
Where it comes fromby source
Income by monthCLP / month
`;
C.hbars($('#in-src'), srcs.map(([label, d], i) => ({
label, value: d.value, color: i < palette.length ? palette[i] : col.mute(),
sub: d.txns.length + ' deposits · ' + Math.round(d.value / total * 100) + '%',
_txns: d.txns, _c: i < palette.length ? palette[i] : col.mute()
})), { max: srcs[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
// donut top 5 + other
const top5 = srcs.slice(0, 5);
const otherV = total - top5.reduce((a, [, d]) => a + d.value, 0);
const donutData = top5.map(([label, d], i) => ({ label, value: d.value, color: palette[i] }));
if (otherV > 0) donutData.push({ label: 'Other sources', value: otherV, color: col.mute() });
C.donut($('#in-donut'), donutData, { size: 176, centerTop: window.MT.CLPk(total).replace('$', '$'), centerBot: 'total in' });
$('#in-chips').innerHTML = donutData.map(d => `${esc(d.label)} ${Math.round(d.value / total * 100)}%`).join('');
C.monthlyBars($('#in-month'), months, [{ key: 'in', label: 'Income', color: col.income(), values: incByM }], { height: 200 });
}
// ============================================================ TAB 4: CYCLES
function renderCycles(host) {
const tx = TX();
const internal = tx.filter(isInternal);
const total = sum(internal);
const byType = {
'Card payments': { value: sum(tx.filter(t => t.flow_type === 'card_payment')), c: tx.filter(t => t.flow_type === 'card_payment'), color: '#6f7b90', label: 'Account → its own credit card' },
'Credit-line sweeps': { value: sum(tx.filter(t => t.flow_type === 'credit_line')), c: tx.filter(t => t.flow_type === 'credit_line'), color: '#566173', label: 'Cuenta corriente ↔ línea de crédito' },
'Self-transfers': { value: sum(tx.filter(t => t.flow_type === 'self_transfer')), c: tx.filter(t => t.flow_type === 'self_transfer'), color: '#7d899e', label: 'Between your own accounts' }
};
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
const byBank = groupSum(internal, t => t.bank);
const banks = sortEntries(byBank).slice(0, 8);
host.innerHTML = `
×${mult.toFixed(1)}
Your statements show ${window.MT.CLPk(TOT.gross)} of gross movement, but only ${window.MT.CLPk(TOT.realIn + TOT.realOut)} is real money in or out.
${window.MT.CLPk(total)} is internal shuffling — money that never left your perimeter.
What the cycling isby type
i
These flows net to roughly zero — they're you paying your own cards, sweeping credit lines, and moving cash between accounts.
Which banks cycle mostinternal volume
`;
const typeArr = Object.entries(byType);
C.hbars($('#cy-types'), typeArr.map(([label, d]) => ({
label, value: d.value, color: d.color, sub: d.label + ' · ' + d.c.length + ' txns', _txns: d.c, _c: d.color
})), { max: Math.max(...typeArr.map(([, d]) => d.value)), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
C.hbars($('#cy-banks'), banks.map(([label, d]) => ({
label, value: d.value, color: col.internal(), sub: d.txns.length + ' txns', _txns: d.txns, _c: col.internal()
})), { max: banks[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
}
// ============================================================ TAB 5: BANKS
function renderBanks(host) {
const tx = TX();
const raw = window.MT.raw;
const banks = window.MT.banks.map(bk => {
const bt = tx.filter(t => t.bank === bk);
const realIn = sum(bt.filter(t => isIncome(t) || isInterIn(t)));
const realOut = sum(bt.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
const internal = sum(bt.filter(isInternal));
const vol = realIn + realOut + internal;
// docs from raw
let docs = 0; for (const s of raw.statements) if (window.MT.bankName(s.bank) === bk) docs++;
return { bk, txns: bt.length, docs, realIn, realOut, internal, vol, real: realIn + realOut };
}).sort((a, b) => b.vol - a.vol);
const maxVol = Math.max(...banks.map(b => b.vol));
const totalReal = banks.reduce((a, b) => a + b.real, 0);
const totalInt = banks.reduce((a, b) => a + b.internal, 0);
host.innerHTML = `
${kpi('Banks reconstructed', '12', `${window.MT.tx.length} txns · ${raw.statements.length} statements`, col.income())}
${kpi('Most active', banks[0].bk, `${window.MT.CLPk(banks[0].vol)} total volume`, col.income())}
${kpi('Real vs internal', Math.round(totalReal / (totalReal + totalInt) * 100) + '%', 'of all volume is real money', col.fee())}
Per-bank breakdownsorted by volume
| Bank | Txns | Real in | Real out | Internal | Volume | Real vs internal |
`;
const tb = $('#bk-rows');
banks.forEach(b => {
const realPct = b.vol ? b.real / b.vol * 100 : 0;
const tr = document.createElement('tr');
tr.innerHTML = `
${esc(b.bk)} ${b.docs} statements |
${b.txns} |
${b.realIn ? window.MT.CLPk(b.realIn) : '—'} |
${b.realOut ? window.MT.CLPk(b.realOut) : '—'} |
${b.internal ? window.MT.CLPk(b.internal) : '—'} |
${window.MT.CLPk(b.vol)} |
| `;
tb.appendChild(tr);
});
}
// ---------- tab wiring ----------
const RENDERERS = { overview: renderOverview, spending: renderSpending, income: renderIncome, cycles: renderCycles, banks: renderBanks };
const rendered = {};
// shared API for the extra-tabs module (dashboard2.js)
window.MTdash = {
TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi,
isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal,
get TOT() { return TOT; },
register(name, fn) { RENDERERS[name] = fn; }
};
function show(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.tab === tab));
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.id === 'panel-' + tab));
const host = $('#host-' + tab);
// always re-render to pick up resize/tweak changes
RENDERERS[tab](host);
rendered[tab] = true;
localStorage.setItem('mt-dash-tab', tab);
}
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { const cur = document.querySelector('.tab.on'); if (cur && TOT) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); }, 160); });
// expose for tweaks re-render (no-op until data is loaded)
window.MTapply = () => { if (!TOT) return; const cur = document.querySelector('.tab.on'); if (cur) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); };
// ---------- boot ----------
window.MT.load().then(() => {
computeTotals();
$('.brand .sub').textContent = `${window.MT.tx.length} txns · ${window.MT.banks.length} banks · ${window.MT.months.length} months`;
const start = localStorage.getItem('mt-dash-tab') || 'overview';
show(RENDERERS[start] ? start : 'overview');
$('#loading').style.display = 'none';
$('#app').style.display = 'flex';
}).catch(err => { $('#loading').textContent = 'failed to load ledger.json — ' + err.message; });
})();