/* 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(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); // ---------- time granularity (month / year / 5-year) ---------- let GRAN = localStorage.getItem('mt-gran') || 'month'; function periodKey(ym) { const y = ym.slice(0, 4); if (GRAN === 'year') return y; if (GRAN === 'p5') { const b = Math.floor(+y / 5) * 5; return b + '–' + (b + 4); } return ym; } function periodLabel(k) { return GRAN === 'month' ? window.MT.MONTH_LABEL(k) : k; } function periodKeys() { return [...new Set(window.MT.months.map(periodKey))].sort(); } function sumByPeriod(txns, pred) { const o = {}; periodKeys().forEach(k => o[k] = 0); for (const t of txns) if (pred(t)) { const k = periodKey(t.ym); if (k in o) o[k] += t.amount; } return o; } const granLabelFn = () => (GRAN === 'month' ? undefined : periodLabel); const granPill = () => (GRAN === 'year' ? 'by year, CLP' : GRAN === 'p5' ? 'by 5-year period, CLP' : 'monthly, CLP'); 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 keys = periodKeys(); const inByM = sumByPeriod(tx, t => isIncome(t) || isInterIn(t)); const outByM = sumByPeriod(tx, t => isExpense(t) || isFee(t) || isInterOut(t)); const netByM = {}; keys.forEach(k => netByM[k] = inByM[k] - outByM[k]); 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${granPill()}

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'), keys, [ { key: 'in', label: 'Money in', color: col.income(), values: inByM }, { key: 'out', label: 'Money out', color: col.spend(), values: outByM } ], { height: 230, labelFn: granLabelFn() }); 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'), keys, netByM, { height: 200, color: col.income(), labelFn: granLabelFn() }); } // ============================================================ 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 spKeys = periodKeys(); const spendByM = sumByPeriod(spend, () => true); // top merchants const byMerch = groupSum(tx.filter(isExpense), t => 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${granPill()}

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'; const logo = window.MTlogo ? window.MTlogo.html(name, 22, {category: cat}) : ''; row.innerHTML = `${i + 1}${logo}${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'), spKeys, [{ key: 'sp', label: 'Spending', color: col.spend(), values: spendByM }], { height: 200, labelFn: granLabelFn() }); 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 => 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); const blogo = window.MTlogo ? window.MTlogo.html(desc, 18, {category: catName}) : ""; return `
${d.txns.length}× ${cpct}% ${MT.CLPk(d.value)}
`; }).join('') + (descs.length > 30 ? `
+${descs.length - 30} more descriptions
` : ''); section.innerHTML = `
${esc(catName)} ${catData.txns.length} txns ${pct}% ${MT.CLPk(catData.value)}
`; 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 inKeys = periodKeys(); const incByM = sumByPeriod(inc, () => true); 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${granPill()}

`; 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'), inKeys, [{ key: 'in', label: 'Income', color: col.income(), values: incByM }], { height: 200, labelFn: granLabelFn() }); } // ============================================================ 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))); // granularity toggle (Month / Year / 5-Year) function rerenderActive() { const cur = document.querySelector('.tab.on'); if (cur && TOT) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); } document.querySelectorAll('#gran-seg button').forEach(b => { b.classList.toggle('on', b.dataset.g === GRAN); b.addEventListener('click', () => { GRAN = b.dataset.g; localStorage.setItem('mt-gran', GRAN); document.querySelectorAll('#gran-seg button').forEach(x => x.classList.toggle('on', x === b)); rerenderActive(); }); }); 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; }); })();