328 lines
20 KiB
JavaScript
328 lines
20 KiB
JavaScript
/* 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 = `<div class="t-head"><span class="t-dot" style="background:${c || col.mute()}"></span><span class="t-title">${esc(title)}</span></div><div class="t-meta" style="margin:0">${esc(sub)}</div>`;
|
||
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 = `<div class="t-head"><span class="t-dot" style="background:${c}"></span><span class="t-title">${esc(title)}</span></div>
|
||
<div class="t-amt num" style="color:${c}">${window.MT.CLP(txns.reduce((a, t) => a + t.amount, 0))}</div>
|
||
<div class="t-meta">${txns.length} transactions</div><div class="t-rows">`;
|
||
for (const t of top) html += `<div class="t-row"><span class="d">${t.date.slice(5).replace('-', '/')}</span><span class="desc">${esc(window.MT.cleanDesc(t.description) || t.counterparty || '—')}</span><span class="a">${window.MT.CLP(t.amount)}</span></div>`;
|
||
html += '</div>';
|
||
if (txns.length > 6) html += `<div class="t-more">+${txns.length - 6} more</div>`;
|
||
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 }) + '<small>M</small>';
|
||
if (a >= 1e3) return sign + '$' + Math.round(a / 1e3).toLocaleString('es-CL') + '<small>k</small>';
|
||
return sign + window.MT.CLP(a);
|
||
};
|
||
const kpi = (lab, val, sub, accent) =>
|
||
`<div class="kpi" style="--accent:${accent}"><div class="k-lab">${lab}</div><div class="k-val">${val}</div>${sub ? `<div class="k-sub">${sub}</div>` : ''}</div>`;
|
||
|
||
// ============================================================ 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 <b>${months.length} months</b>, <b>${window.MT.CLPk(TOT.realIn)}</b> came in and <b>${window.MT.CLPk(TOT.realOut)}</b> went out — but your statements logged <b>${window.MT.CLPk(TOT.gross)}</b> 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 = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${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())}
|
||
</div>
|
||
<div class="grid g-2-1">
|
||
<div class="card tall"><h3>Income vs spending by month<span class="pill">monthly, CLP</span></h3><div class="chart-host" id="ov-bars"></div>
|
||
<div class="chips"><span class="chip"><i style="background:${col.income()}"></i>Money in</span><span class="chip"><i style="background:${col.spend()}"></i>Money out</span></div>
|
||
</div>
|
||
<div class="card tall"><h3>Real vs internal</h3>
|
||
<div class="donut-wrap"><div id="ov-donut"></div>
|
||
<div class="chips" style="flex-direction:column;gap:10px;margin:0;">
|
||
<span class="chip"><i style="background:${col.income()}"></i>Real money <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b></span>
|
||
<span class="chip"><i style="background:${col.internal()}"></i>Internal shuffle <b>${window.MT.CLPk(TOT.internal)}</b></span>
|
||
</div>
|
||
</div>
|
||
<div class="insight" style="margin-top:18px;"><div class="i-ic">↻</div><div>For every <b>$1</b> of real money, <span class="num">$${mult.toFixed(1)}</span> moved between your own accounts. Most of the statement volume is noise.</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;"><h3>Cumulative net position<span class="pill">running income − spending</span></h3><div class="chart-host" id="ov-line"></div></div>
|
||
`;
|
||
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 = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${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())}
|
||
</div>
|
||
<div class="grid g-2-1">
|
||
<div class="card"><h3>Where it actually goes<span class="pill">by category</span></h3><div id="sp-cats"></div></div>
|
||
<div class="card"><h3>Top merchants<span class="pill">single payees</span></h3><div class="mlist" id="sp-merch"></div></div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;"><h3>Spending by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="sp-month"></div></div>
|
||
`;
|
||
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 = `<span class="m-rank">${i + 1}</span><span class="m-name">${esc(name)}<span class="m-cat">${esc(cat)}</span></span><span class="m-cnt">${d.txns.length}×</span><span class="m-amt">${window.MT.CLPk(d.value)}</span>`;
|
||
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 = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${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())}
|
||
</div>
|
||
<div class="grid g-2-1">
|
||
<div class="card"><h3>Where it comes from<span class="pill">by source</span></h3><div id="in-src"></div></div>
|
||
<div class="card"><h3>Concentration</h3>
|
||
<div class="donut-wrap"><div id="in-donut"></div></div>
|
||
<div class="chips" id="in-chips" style="margin-top:18px;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;"><h3>Income by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="in-month"></div></div>
|
||
`;
|
||
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 => `<span class="chip"><i style="background:${d.color}"></i>${esc(d.label)} <b>${Math.round(d.value / total * 100)}%</b></span>`).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 = `
|
||
<div class="card" style="margin-bottom:16px;">
|
||
<div class="callout"><div class="big mult">×${mult.toFixed(1)}</div><div class="ctxt">Your statements show <b>${window.MT.CLPk(TOT.gross)}</b> of gross movement, but only <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b> is real money in or out.<br/><b>${window.MT.CLPk(total)}</b> is internal shuffling — money that never left your perimeter.</div></div>
|
||
</div>
|
||
<div class="grid g-2">
|
||
<div class="card"><h3>What the cycling is<span class="pill">by type</span></h3><div id="cy-types"></div>
|
||
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>These flows net to roughly zero — they're you paying your own cards, sweeping credit lines, and moving cash between accounts.</div></div>
|
||
</div>
|
||
<div class="card"><h3>Which banks cycle most<span class="pill">internal volume</span></h3><div id="cy-banks"></div></div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${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())}
|
||
</div>
|
||
<div class="card"><h3>Per-bank breakdown<span class="pill">sorted by volume</span></h3>
|
||
<table class="btable"><thead><tr>
|
||
<th>Bank</th><th>Txns</th><th>Real in</th><th>Real out</th><th>Internal</th><th>Volume</th><th style="width:150px;">Real vs internal</th>
|
||
</tr></thead><tbody id="bk-rows"></tbody></table>
|
||
</div>
|
||
`;
|
||
const tb = $('#bk-rows');
|
||
banks.forEach(b => {
|
||
const realPct = b.vol ? b.real / b.vol * 100 : 0;
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><div class="bk-name"><span class="bk-dot" style="background:${b.real >= b.internal ? col.income() : col.internal()}"></span><div>${esc(b.bk)}<div class="bk-meta">${b.docs} statements</div></div></div></td>
|
||
<td>${b.txns}</td>
|
||
<td style="color:${col.income()}">${b.realIn ? window.MT.CLPk(b.realIn) : '<span class="muted">—</span>'}</td>
|
||
<td style="color:${col.spend()}">${b.realOut ? window.MT.CLPk(b.realOut) : '<span class="muted">—</span>'}</td>
|
||
<td class="muted">${b.internal ? window.MT.CLPk(b.internal) : '—'}</td>
|
||
<td>${window.MT.CLPk(b.vol)}</td>
|
||
<td><span class="minibar" title="${Math.round(realPct)}% real"><i style="width:${realPct}%;background:${col.income()}"></i><i style="width:${100 - realPct}%;background:${col.internal()}"></i></span></td>`;
|
||
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; });
|
||
})();
|