kua-money-trace/web/dashboard.js

375 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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(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 => 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>
<div class="card" style="margin-top:16px;"><h3>Categories &amp; merchants<span class="pill">click to expand</span></h3><div id="sp-breakdown"></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';
const logo = window.MTlogo ? window.MTlogo.html(name, 22, {category: cat}) : '';
row.innerHTML = `<span class="m-rank">${i + 1}</span><span class="m-name">${logo}<span>${esc(name)}<span class="m-cat">${esc(cat)}</span></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 });
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 `<div class="bk-row">
<span class="bk-row-desc with-logo">${blogo}<span>${esc(desc)}</span></span>
<span class="bk-row-cnt">${d.txns.length}×</span>
<span class="bk-row-pct">${cpct}%</span>
<span class="bk-row-amt">${MT.CLPk(d.value)}</span>
</div>`;
}).join('') + (descs.length > 30 ? `<div class="bk-more">+${descs.length - 30} more descriptions</div>` : '');
section.innerHTML = `
<div class="bk-header">
<span class="bk-toggle">▶</span>
<span class="bk-dot" style="background:${color}"></span>
<span class="bk-name">${esc(catName)}</span>
<span class="bk-meta">${catData.txns.length} txns</span>
<span class="bk-pct">${pct}%</span>
<span class="bk-amt">${MT.CLPk(catData.value)}</span>
</div>
<div class="bk-rows" hidden>${rowsHtml}</div>`;
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 = `
<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; });
})();