/* 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

`; 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 }); } // ============================================================ 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

Concentration

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

BankTxnsReal inReal outInternalVolumeReal 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; }); })();