436 lines
27 KiB
JavaScript
436 lines
27 KiB
JavaScript
/* money-trace · dashboard part 2 — extended perspective tabs
|
||
Balances · People · Cards & credit · Rhythm · Ledger explorer · Data quality
|
||
Registers into the controller via window.MTdash.register(). */
|
||
(function () {
|
||
'use strict';
|
||
const D = window.MTdash, C = window.Charts, MT = window.MT;
|
||
const { TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi } = D;
|
||
const { isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal } = D;
|
||
const $ = s => document.querySelector(s);
|
||
const CLPk = MT.CLPk, CLP = MT.CLP;
|
||
const ms = ymd => +new Date(ymd + 'T00:00:00');
|
||
|
||
// palette for many-series
|
||
const SERIES_COLORS = ['#2ee6a6', '#6fd0ff', '#b98cff', '#ffc24b', '#ff8f6b', '#5fd0a0', '#8c9eff', '#ff5f73', '#d0d6e0', '#46c6ba'];
|
||
|
||
// ============================================================ BALANCES & RUNWAY
|
||
function renderBalances(host) {
|
||
const tx = TX();
|
||
// group transactions that carry a balance, by account
|
||
const acctMap = {};
|
||
for (const t of tx) {
|
||
if (t.balance == null || t.doc_type === 'tarjeta_credito') continue;
|
||
const key = t.bank + '·' + (t.last4 || t.doc_type) + '·' + t.doc_type;
|
||
(acctMap[key] = acctMap[key] || { bank: t.bank, last4: t.last4, doc_type: t.doc_type, pts: [] });
|
||
acctMap[key].pts.push({ t: ms(t.date), v: t.balance, tx: t });
|
||
}
|
||
// keep accounts with >=4 points, sort by point count
|
||
let accts = Object.entries(acctMap).map(([k, a]) => ({ key: k, ...a }))
|
||
.filter(a => a.pts.length >= 4)
|
||
.sort((a, b) => b.pts.length - a.pts.length);
|
||
accts.forEach((a, i) => { a.color = SERIES_COLORS[i % SERIES_COLORS.length]; a.pts.sort((p, q) => p.t - q.t); a.last = a.pts[a.pts.length - 1].v; a.min = Math.min(...a.pts.map(p => p.v)); });
|
||
|
||
// default selection: top 4
|
||
if (!host._sel) host._sel = new Set(accts.slice(0, 4).map(a => a.key));
|
||
const sel = host._sel;
|
||
const lowest = accts.slice().sort((a, b) => a.min - b.min)[0];
|
||
const liquid = accts.filter(a => a.doc_type !== 'linea_credito').reduce((s, a) => s + a.last, 0);
|
||
|
||
host.innerHTML = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${kpi('Accounts with balances', accts.length, 'of 17 reconstructed accounts', col.income())}
|
||
${kpi('Latest liquid total', fmtBig(liquid), 'sum of last-known cash balances', col.income())}
|
||
${kpi('Lowest point', fmtBig(lowest ? lowest.min : 0), lowest ? `${esc(lowest.bank)} ··${lowest.last4 || ''}` : '', lowest && lowest.min < 0 ? col.spend() : col.fee())}
|
||
</div>
|
||
<div class="card">
|
||
<h3>Balance over time<span class="pill">running balance, where statements report it</span></h3>
|
||
<div class="acct-chips" id="bal-chips"></div>
|
||
<div class="chart-host" id="bal-chart"></div>
|
||
</div>
|
||
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Credit cards don't report a running balance in these statements, so they're excluded here. Coverage varies by account — lines connect only the points each statement actually logged.</div></div>
|
||
`;
|
||
const chips = $('#bal-chips');
|
||
accts.forEach(a => {
|
||
const c = document.createElement('div'); c.className = 'acct-chip' + (sel.has(a.key) ? ' on' : '');
|
||
c.innerHTML = `<i style="background:${a.color}"></i>${esc(a.bank)}<span class="ac-end">${MT.acctShort(a.doc_type)}${a.last4 ? ' ··' + a.last4 : ''}</span>`;
|
||
c.addEventListener('click', () => { sel.has(a.key) ? sel.delete(a.key) : sel.add(a.key); renderBalances(host); });
|
||
chips.appendChild(c);
|
||
});
|
||
const series = accts.filter(a => sel.has(a.key)).map(a => ({
|
||
label: a.bank + ' ' + MT.acctShort(a.doc_type) + (a.last4 ? ' ··' + a.last4 : ''),
|
||
color: a.color, points: a.pts
|
||
}));
|
||
C.multiLine($('#bal-chart'), series, { height: 320 });
|
||
}
|
||
|
||
// ============================================================ PEOPLE
|
||
function renderPeople(host) {
|
||
const tx = TX();
|
||
// build per-person ledger from income (in), inter_person (in/out)
|
||
const ppl = {};
|
||
function add(name, t, dir) {
|
||
const k = name;
|
||
(ppl[k] = ppl[k] || { name, in: 0, out: 0, inTx: [], outTx: [], dates: [] });
|
||
if (dir === 'in') { ppl[k].in += t.amount; ppl[k].inTx.push(t); }
|
||
else { ppl[k].out += t.amount; ppl[k].outTx.push(t); }
|
||
ppl[k].dates.push(t.date);
|
||
}
|
||
for (const t of tx) {
|
||
if (isIncome(t)) add(MT.normIncome(t.counterparty), t, 'in');
|
||
else if (isInterIn(t)) add(MT.normPerson(t.counterparty), t, 'in');
|
||
else if (isInterOut(t)) add(MT.normPerson(t.counterparty), t, 'out');
|
||
}
|
||
let people = Object.values(ppl).map(p => {
|
||
p.net = p.in - p.out; p.gross = p.in + p.out; p.n = p.inTx.length + p.outTx.length;
|
||
p.dates.sort(); p.first = p.dates[0]; p.last = p.dates[p.dates.length - 1];
|
||
return p;
|
||
}).filter(p => p.name && p.name !== '—').sort((a, b) => b.gross - a.gross);
|
||
|
||
const payers = people.filter(p => p.net > 0).slice(0, 1);
|
||
const namedIncome = people.reduce((s, p) => s + p.in, 0);
|
||
|
||
host.innerHTML = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${kpi('People & payers', people.length, 'distinct counterparties with a name', col.person())}
|
||
${kpi('Biggest net source', payers.length ? esc(payers[0].name) : '—', payers.length ? '+' + CLPk(payers[0].net) + ' net to you' : '', col.income())}
|
||
${kpi('Named-source income', fmtBig(namedIncome), 'total in from identified people', col.income())}
|
||
</div>
|
||
<div class="card"><h3>Your money relationships<span class="pill">money in · money out · net</span></h3>
|
||
<table class="btable"><thead><tr>
|
||
<th>Person / payer</th><th>Txns</th><th>Money in</th><th>Money out</th><th>Net</th><th style="width:170px;">In vs out</th><th>Last seen</th>
|
||
</tr></thead><tbody id="ppl-rows"></tbody></table>
|
||
</div>
|
||
`;
|
||
const tb = $('#ppl-rows');
|
||
const maxGross = Math.max(...people.map(p => p.gross), 1);
|
||
people.forEach(p => {
|
||
const inPct = p.gross ? p.in / p.gross * 100 : 0;
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><div class="bk-name"><span class="bk-dot" style="background:${p.net >= 0 ? col.income() : col.spend()}"></span>${esc(p.name)}</div></td>
|
||
<td>${p.n}</td>
|
||
<td style="color:${col.income()}">${p.in ? CLPk(p.in) : '<span class="muted">—</span>'}</td>
|
||
<td style="color:${col.spend()}">${p.out ? CLPk(p.out) : '<span class="muted">—</span>'}</td>
|
||
<td class="${p.net >= 0 ? 'net-pos' : 'net-neg'}">${p.net >= 0 ? '+' : '−'}${CLPk(Math.abs(p.net))}</td>
|
||
<td><span class="minibar" style="width:${10 + p.gross / maxGross * 140}px"><i style="width:${inPct}%;background:${col.income()}"></i><i style="width:${100 - inPct}%;background:${col.spend()}"></i></span></td>
|
||
<td class="muted" style="font-size:11px;">${p.last ? p.last.slice(0, 7) : ''}</td>`;
|
||
tr.addEventListener('mousemove', e => window.MTtip.rows(e, p.name, p.net >= 0 ? col.income() : col.spend(), [...p.inTx, ...p.outTx]));
|
||
tr.addEventListener('mouseleave', () => window.MTtip.hide());
|
||
tb.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// ============================================================ CARDS & CREDIT
|
||
function renderCards(host) {
|
||
const tx = TX();
|
||
const fees = tx.filter(isFee);
|
||
const feeTotal = sum(fees);
|
||
// classify fee descriptions
|
||
const feeCat = d => {
|
||
const u = (d || '').toUpperCase();
|
||
if (/INTERES|MORA|ROTATIV/.test(u)) return 'Interest';
|
||
if (/IMPUESTO|DL 3475|DECRETO/.test(u)) return 'Taxes & stamp';
|
||
if (/COBRANZA/.test(u)) return 'Collection charges';
|
||
if (/COMIS|MANTENCION|MANTENC|ADMIN|SERVICIO|CARGO/.test(u)) return 'Commissions & maintenance';
|
||
return 'Other charges';
|
||
};
|
||
const byFee = groupSum(fees, t => feeCat(t.description));
|
||
const feeArr = sortEntries(byFee);
|
||
const FEE_C = { 'Interest': '#ff5f73', 'Taxes & stamp': '#ffc24b', 'Collection charges': '#ff8f6b', 'Commissions & maintenance': '#b98cff', 'Other charges': '#7a8699' };
|
||
|
||
// credit-line activity: draws (debit on cuenta = borrowing) vs repayments
|
||
const lineTx = tx.filter(t => t.flow_type === 'credit_line');
|
||
const cardPay = tx.filter(t => t.flow_type === 'card_payment');
|
||
const cardPayTotal = sum(cardPay);
|
||
const months = MT.months;
|
||
const feeByM = {}; months.forEach(m => feeByM[m] = sum(fees.filter(t => t.ym === m)));
|
||
|
||
// which cards/lines exist
|
||
const cardAccts = {};
|
||
for (const t of tx) {
|
||
if (!MT.isCardType(t.doc_type)) continue;
|
||
const k = t.bank + ' · ' + MT.acctShort(t.doc_type);
|
||
(cardAccts[k] = cardAccts[k] || { bank: t.bank, type: t.doc_type, fees: 0, spend: 0, n: 0 });
|
||
cardAccts[k].n++;
|
||
if (isFee(t)) cardAccts[k].fees += t.amount;
|
||
if (isExpense(t)) cardAccts[k].spend += t.amount;
|
||
}
|
||
const cardList = Object.entries(cardAccts).sort((a, b) => (b[1].fees + b[1].spend) - (a[1].fees + a[1].spend));
|
||
|
||
host.innerHTML = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${kpi('Paid in fees & interest', fmtBig(feeTotal), `${fees.length} charges across all cards & lines`, col.fee())}
|
||
${kpi('Card payments made', fmtBig(cardPayTotal), `${cardPay.length} payments to your own cards`, col.internal())}
|
||
${kpi('Interest alone', fmtBig(byFee['Interest'] ? byFee['Interest'].value : 0), Math.round((byFee['Interest'] ? byFee['Interest'].value : 0) / feeTotal * 100) + '% of all charges', col.spend())}
|
||
</div>
|
||
<div class="grid g-2-1">
|
||
<div class="card"><h3>What the bank charged you<span class="pill">fees & interest by type</span></h3><div id="cd-fees"></div></div>
|
||
<div class="card"><h3>Charges by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="cd-month"></div></div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;"><h3>Per card & line<span class="pill">spend + fees</span></h3>
|
||
<table class="btable"><thead><tr><th>Card / line</th><th>Txns</th><th>Spend on it</th><th>Fees charged</th><th>Fee rate</th></tr></thead><tbody id="cd-rows"></tbody></table>
|
||
</div>
|
||
`;
|
||
C.hbars($('#cd-fees'), feeArr.map(([label, d]) => ({
|
||
label, value: d.value, color: FEE_C[label] || col.mute(), sub: d.txns.length + ' charges · ' + Math.round(d.value / feeTotal * 100) + '%',
|
||
_txns: d.txns, _c: FEE_C[label] || col.mute()
|
||
})), { max: feeArr[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||
C.monthlyBars($('#cd-month'), months, [{ key: 'f', label: 'Fees & interest', color: col.fee(), values: feeByM }], { height: 200 });
|
||
const tb = $('#cd-rows');
|
||
cardList.forEach(([name, c]) => {
|
||
const rate = c.spend ? c.fees / c.spend * 100 : null;
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><div class="bk-name"><span class="bk-dot" style="background:${col.fee()}"></span>${esc(name)}</div></td>
|
||
<td>${c.n}</td>
|
||
<td>${c.spend ? CLPk(c.spend) : '<span class="muted">—</span>'}</td>
|
||
<td style="color:${col.fee()}">${c.fees ? CLPk(c.fees) : '<span class="muted">—</span>'}</td>
|
||
<td class="muted">${rate != null ? Math.round(rate) + '%' : '—'}</td>`;
|
||
tb.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// ============================================================ RHYTHM / CALENDAR
|
||
function renderRhythm(host) {
|
||
const tx = TX();
|
||
const spend = tx.filter(t => isExpense(t) || isFee(t));
|
||
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
// weekday × (none) totals
|
||
const wdAmt = [0, 0, 0, 0, 0, 0, 0], wdTx = [[], [], [], [], [], [], []];
|
||
for (const t of spend) { const w = new Date(t.date + 'T00:00:00').getDay(); wdAmt[w] += t.amount; wdTx[w].push(t); }
|
||
// month × day-of-month heatmap for spending
|
||
const months = MT.months;
|
||
const grid = {}; months.forEach(m => grid[m] = {});
|
||
for (const t of spend) { const day = +t.date.slice(8, 10); (grid[t.ym][day] = grid[t.ym][day] || { v: 0, raw: [] }); grid[t.ym][day].v += t.amount; grid[t.ym][day].raw.push(t); }
|
||
// busiest weekday
|
||
const peakWd = wdAmt.indexOf(Math.max(...wdAmt));
|
||
const totalSpend = sum(spend);
|
||
const weekendShare = Math.round((wdAmt[0] + wdAmt[6]) / totalSpend * 100);
|
||
const WDLONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||
|
||
host.innerHTML = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${kpi('Busiest spending day', WDLONG[peakWd], CLPk(wdAmt[peakWd]) + ' total spent', col.spend())}
|
||
${kpi('Weekend share', weekendShare + '%', 'of spending lands Sat–Sun', col.fee())}
|
||
${kpi('Active days', Object.values(grid).reduce((s, m) => s + Object.keys(m).length, 0), 'days with at least one purchase', col.income())}
|
||
</div>
|
||
<div class="card"><h3>Spending by day of month<span class="pill">darker = more spent</span></h3><div class="chart-host" id="rh-heat" style="overflow-x:auto;"></div>
|
||
<div class="hm-scale"><span>less</span><div class="sw"><i style="background:color-mix(in oklab, var(--c-spend) 12%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 40%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 70%, var(--bg))"></i><i style="background:var(--c-spend)"></i></div><span>more</span></div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;"><h3>Which weekday do you spend?<span class="pill">total by day of week</span></h3><div id="rh-wd"></div></div>
|
||
`;
|
||
// heatmap: rows = months, cols = days 1..31
|
||
const days = Array.from({ length: 31 }, (_, i) => i + 1);
|
||
const rows = months.map(m => ({
|
||
label: MT.MONTH_LABEL(m),
|
||
cells: days.map(d => {
|
||
const cell = grid[m][d];
|
||
return { v: cell ? cell.v : 0, raw: cell ? cell.raw : [], title: cell ? MT.MONTH_LABEL(m) + ' ' + d + ' · ' + CLP(cell.v) : '' };
|
||
})
|
||
}));
|
||
const heatHost = $('#rh-heat');
|
||
heatHost.style.setProperty('--cols', 31);
|
||
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.setProperty('--cols', 31));
|
||
C.heatmap(heatHost, rows, days.map(d => d % 2 ? d : ''), { color: col.spend() });
|
||
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.gridTemplateColumns = '64px repeat(31, 1fr)');
|
||
// weekday bars
|
||
C.hbars($('#rh-wd'), WDLONG.map((w, i) => ({
|
||
label: w, value: wdAmt[i], color: (i === 0 || i === 6) ? col.fee() : col.spend(),
|
||
_txns: wdTx[i], _c: (i === 0 || i === 6) ? col.fee() : col.spend()
|
||
})), { max: Math.max(...wdAmt), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||
}
|
||
|
||
// ============================================================ LEDGER EXPLORER
|
||
function renderLedger(host) {
|
||
const tx = TX().slice();
|
||
if (!host._state) host._state = { q: '', flow: 'all', bank: 'all', sort: 'date', dir: -1, limit: 60 };
|
||
const st = host._state;
|
||
const FLOW_C = { income: col.income(), inter_person: col.person(), expense: col.spend(), fee: col.fee(), self_transfer: col.internal(), credit_line: col.internal(), card_payment: col.internal() };
|
||
const FLOW_LABEL = { income: 'Income', inter_person: 'Person', expense: 'Spending', fee: 'Fee', self_transfer: 'Self-transfer', credit_line: 'Credit line', card_payment: 'Card payment' };
|
||
|
||
host.innerHTML = `
|
||
<div class="lx-controls">
|
||
<div class="lx-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
|
||
<input id="lx-q" placeholder="Search description, counterparty, bank…" value="${esc(st.q)}" /></div>
|
||
<select class="lx-select" id="lx-flow"></select>
|
||
<select class="lx-select" id="lx-bank"></select>
|
||
<span class="lx-count" id="lx-count"></span>
|
||
</div>
|
||
<div class="lx-table-wrap"><table class="lx-table"><thead><tr>
|
||
<th data-s="date">Date</th><th data-s="desc">Description</th><th data-s="bank">Account</th>
|
||
<th data-s="flow">Type</th><th class="r" data-s="amount">Amount</th>
|
||
</tr></thead><tbody id="lx-rows"></tbody></table><div id="lx-more"></div></div>
|
||
`;
|
||
// flow select
|
||
const flowOpts = ['all', 'income', 'inter_person', 'expense', 'fee', 'self_transfer', 'credit_line', 'card_payment'];
|
||
$('#lx-flow').innerHTML = flowOpts.map(f => `<option value="${f}"${st.flow === f ? ' selected' : ''}>${f === 'all' ? 'All types' : FLOW_LABEL[f]}</option>`).join('');
|
||
$('#lx-bank').innerHTML = `<option value="all">All banks</option>` + MT.banks.map(b => `<option value="${esc(b)}"${st.bank === b ? ' selected' : ''}>${esc(b)}</option>`).join('');
|
||
|
||
function apply() {
|
||
let rows = tx;
|
||
const q = st.q.trim().toLowerCase();
|
||
if (q) rows = rows.filter(t => (t.description || '').toLowerCase().includes(q) || (t.counterparty || '').toLowerCase().includes(q) || t.bank.toLowerCase().includes(q));
|
||
if (st.flow !== 'all') rows = rows.filter(t => t.flow_type === st.flow);
|
||
if (st.bank !== 'all') rows = rows.filter(t => t.bank === st.bank);
|
||
rows.sort((a, b) => {
|
||
let av, bv;
|
||
if (st.sort === 'date') { av = a.date; bv = b.date; }
|
||
else if (st.sort === 'amount') { av = a.amount; bv = b.amount; }
|
||
else if (st.sort === 'bank') { av = a.bank; bv = b.bank; }
|
||
else if (st.sort === 'flow') { av = a.flow_type; bv = b.flow_type; }
|
||
else { av = a.description || ''; bv = b.description || ''; }
|
||
return (av < bv ? -1 : av > bv ? 1 : 0) * st.dir;
|
||
});
|
||
$('#lx-count').textContent = rows.length + ' of ' + tx.length + ' transactions';
|
||
const shown = rows.slice(0, st.limit);
|
||
$('#lx-rows').innerHTML = shown.map(t => `
|
||
<tr><td class="lx-date">${t.date}</td>
|
||
<td class="lx-desc" title="${esc(t.description)}">${esc(MT.cleanDesc(t.description) || t.counterparty || '—')}</td>
|
||
<td style="white-space:nowrap;color:var(--ink-mute)">${esc(t.bank)} <span class="lx-pdf">${MT.acctShort(t.doc_type)}${t.last4 ? ' ··' + t.last4 : ''}</span></td>
|
||
<td><span class="lx-flow-tag"><i style="background:${FLOW_C[t.flow_type] || col.mute()}"></i>${FLOW_LABEL[t.flow_type] || t.flow_type}</span></td>
|
||
<td class="r lx-amt ${t.direction === 'credit' ? 'cr' : 'db'}">${t.direction === 'credit' ? '+' : '−'}${CLP(t.amount)}</td></tr>`).join('');
|
||
$('#lx-more').innerHTML = rows.length > st.limit ? `<div class="lx-more">Show ${Math.min(60, rows.length - st.limit)} more (${rows.length - st.limit} hidden)</div>` : '';
|
||
const more = $('#lx-more').querySelector('.lx-more');
|
||
if (more) more.addEventListener('click', () => { st.limit += 60; apply(); });
|
||
}
|
||
$('#lx-q').addEventListener('input', e => { st.q = e.target.value; st.limit = 60; apply(); });
|
||
$('#lx-flow').addEventListener('change', e => { st.flow = e.target.value; st.limit = 60; apply(); });
|
||
$('#lx-bank').addEventListener('change', e => { st.bank = e.target.value; st.limit = 60; apply(); });
|
||
host.querySelectorAll('.lx-table th[data-s]').forEach(th => th.addEventListener('click', () => {
|
||
const s = th.dataset.s; if (st.sort === s) st.dir *= -1; else { st.sort = s; st.dir = s === 'date' || s === 'amount' ? -1 : 1; } apply();
|
||
}));
|
||
apply();
|
||
}
|
||
|
||
// ============================================================ DATA QUALITY
|
||
function renderQuality(host) {
|
||
const tx = TX();
|
||
const raw = MT.raw;
|
||
const months = MT.months;
|
||
// statement coverage: account -> set of months covered (from period_start..period_end)
|
||
const acctCov = {};
|
||
for (const s of raw.statements) {
|
||
const key = MT.bankName(s.bank) + ' · ' + MT.acctShort(s.doc_type) + (s.account_last4 ? ' ··' + s.account_last4 : '');
|
||
(acctCov[key] = acctCov[key] || { months: new Set(), docs: 0, bank: MT.bankName(s.bank) });
|
||
acctCov[key].docs++;
|
||
if (s.period_start && s.period_end) {
|
||
let c = new Date(s.period_start.slice(0, 7) + '-01T00:00:00');
|
||
const end = new Date(s.period_end.slice(0, 7) + '-01T00:00:00');
|
||
while (c <= end) { acctCov[key].months.add(c.toISOString().slice(0, 7)); c.setMonth(c.getMonth() + 1); }
|
||
}
|
||
}
|
||
const accts = Object.entries(acctCov).sort((a, b) => b[1].docs - a[1].docs);
|
||
|
||
// metrics
|
||
const withBal = tx.filter(t => t.balance != null).length;
|
||
const withPeriod = raw.statements.filter(s => s.period_start && s.period_end).length;
|
||
const classified = tx.filter(t => t.flow_type !== 'expense' || MT.spendCategory(t.description) !== 'Other purchases').length;
|
||
const otherSpend = tx.filter(t => t.flow_type === 'expense' && MT.spendCategory(t.description) === 'Other purchases').length;
|
||
const namedCp = tx.filter(t => t.counterparty).length;
|
||
const pct = (a, b) => Math.round(a / b * 100);
|
||
|
||
function metric(v, total, label, c) {
|
||
return `<div class="qstat"><div class="qv">${pct(v, total)}%</div><div class="ql">${label}</div><div class="qbar"><i style="width:${pct(v, total)}%;background:${c}"></i><i style="width:${100 - pct(v, total)}%;background:var(--line)"></i></div></div>`;
|
||
}
|
||
host.innerHTML = `
|
||
<div class="qstats">
|
||
${metric(withPeriod, raw.statements.length, 'Statements with a clear period', col.income())}
|
||
${metric(withBal, tx.length, 'Transactions with a running balance', col.fee())}
|
||
${metric(namedCp, tx.length, 'Transactions with a named counterparty', col.person())}
|
||
${metric(tx.length - otherSpend, tx.length, 'Transactions confidently categorized', col.income())}
|
||
</div>
|
||
<div class="card"><h3>Statement coverage<span class="pill">${accts.length} accounts × ${months.length} months</span></h3>
|
||
<div class="cov-grid" id="cov-grid"></div>
|
||
<div class="hm-scale"><span style="color:var(--ink-dim)">Each filled cell = a statement covers that month. Gaps = months with no statement on file.</span></div>
|
||
</div>
|
||
<div class="grid g-2" style="margin-top:16px;">
|
||
<div class="insight"><div class="i-ic">!</div><div><b>${otherSpend}</b> purchases (${pct(otherSpend, tx.length)}%) couldn't be matched to a spending category and sit in "Other purchases" — the raw statements carry no merchant field, so these are inferred from free-text descriptions.</div></div>
|
||
<div class="insight"><div class="i-ic">i</div><div>Credit-card statements report transactions but <b>not</b> running balances, so the Balances tab covers cash and credit-line accounts only.</div></div>
|
||
</div>
|
||
`;
|
||
// coverage grid
|
||
const grid = $('#cov-grid');
|
||
grid.style.setProperty('--cols', months.length);
|
||
const head = document.createElement('div'); head.className = 'cov-row cov-head'; head.style.setProperty('--cols', months.length);
|
||
head.innerHTML = `<div class="cov-rowlab"></div>` + months.map(m => `<div class="cov-collab">${MT.MONTH_LABEL(m)}</div>`).join('');
|
||
grid.appendChild(head);
|
||
accts.forEach(([name, a]) => {
|
||
const row = document.createElement('div'); row.className = 'cov-row'; row.style.setProperty('--cols', months.length);
|
||
let html = `<div class="cov-rowlab"><span class="cov-dot" style="background:${col.income()}"></span><div>${esc(name)}<div class="cov-meta">${a.docs} stmts · ${a.months.size} mo</div></div></div>`;
|
||
months.forEach(m => { html += `<div class="cov-cell${a.months.has(m) ? ' has' : ''}" title="${esc(name)} · ${MT.MONTH_LABEL(m)}${a.months.has(m) ? ' — covered' : ' — no statement'}"></div>`; });
|
||
row.innerHTML = html;
|
||
grid.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// ============================================================ PLATFORMS
|
||
const RAILS = [
|
||
['MercadoPago', /MERCADO ?PAGO|MERPAGO|MERPAG|MP —|MP\*/i, '#6fd0ff'],
|
||
['MACH', /\bMACH\b/i, '#2ee6a6'],
|
||
['WebPay', /WEBPAY|WEB PAY/i, '#b98cff'],
|
||
['PayU', /\bPAYU\b/i, '#ffc24b'],
|
||
['Rappi', /RAPPI/i, '#ff8f6b'],
|
||
['Uber', /\bUBER\b|UBER ?EATS/i, '#5fd0a0'],
|
||
['SumUp', /SUMUP/i, '#8c9eff'],
|
||
['Payscan', /PAYSCAN/i, '#ff5f73'],
|
||
['Tuu', /\bTUU\b|TUU\*/i, '#d0d6e0'],
|
||
['Fintoc', /FINTOC/i, '#46c6ba'],
|
||
['Servipag', /SERVIPAG/i, '#ffd45a'],
|
||
['Sencillito', /SENCILLITO/i, '#c9a6ff'],
|
||
['Khipu', /KHIPU/i, '#7CFFB2'],
|
||
['Kushki', /KUSHKI/i, '#FF8FA3'],
|
||
['OneClick', /ONECLICK|ONE CLICK/i, '#6fd0ff'],
|
||
['Compraqui', /COMPRAQUI/i, '#ff8f6b']
|
||
];
|
||
function railOf(t) {
|
||
if (t.platform) { for (const [n, re] of RAILS) if (re.test(t.platform)) return n; }
|
||
for (const [n, re] of RAILS) if (re.test(t.description || '')) return n;
|
||
return null;
|
||
}
|
||
const railColor = name => (RAILS.find(r => r[0] === name) || [, , col.mute()])[2];
|
||
|
||
function renderPlatforms(host) {
|
||
const tx = TX();
|
||
const railTx = {};
|
||
for (const t of tx) { const r = railOf(t); if (!r) continue; (railTx[r] = railTx[r] || []).push(t); }
|
||
let rails = Object.entries(railTx).map(([name, txns]) => {
|
||
const out = txns.filter(t => t.direction === 'debit'), inn = txns.filter(t => t.direction === 'credit');
|
||
return { name, txns, n: txns.length, amt: sum(txns), out: sum(out), in: sum(inn), color: railColor(name) };
|
||
}).sort((a, b) => b.amt - a.amt);
|
||
const totalAmt = rails.reduce((s, r) => s + r.amt, 0);
|
||
const totalN = rails.reduce((s, r) => s + r.n, 0);
|
||
const byCount = rails.slice().sort((a, b) => b.n - a.n);
|
||
const outTotal = rails.reduce((s, r) => s + r.out, 0), inTotal = rails.reduce((s, r) => s + r.in, 0);
|
||
|
||
host.innerHTML = `
|
||
<div class="grid g-3" style="margin-bottom:16px;">
|
||
${kpi('Payment rails seen', rails.length, `across ${totalN} transactions`, col.person())}
|
||
${kpi('Most used', byCount.length ? esc(byCount[0].name) : '—', byCount.length ? byCount[0].n + ' transactions' : '', byCount.length ? byCount[0].color : col.mute())}
|
||
${kpi('Routed through platforms', fmtBig(totalAmt), '~' + Math.round(totalN / tx.length * 100) + '% of all transactions', col.income())}
|
||
</div>
|
||
<div class="grid g-2-1">
|
||
<div class="card"><h3>Platforms by value<span class="pill">money moved per rail</span></h3><div id="pf-amt"></div></div>
|
||
<div class="card"><h3>By transaction count<span class="pill">how often used</span></h3><div id="pf-cnt"></div></div>
|
||
</div>
|
||
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Rails are detected from the <b>platform</b> field and transaction descriptions, so this covers the ~${Math.round(totalN / tx.length * 100)}% of transactions that name a processor. <b>Servipag</b> and <b>MACH</b> move large sums in a few bill-payments, while <b>MercadoPago</b> and <b>PayU</b> appear in many small card purchases.</div></div>
|
||
`;
|
||
C.hbars($('#pf-amt'), rails.map(r => ({
|
||
label: r.name, value: r.amt, color: r.color,
|
||
sub: r.n + ' txns' + (r.in && r.out ? ' · in+out' : r.in ? ' · money in' : ' · purchases'),
|
||
_txns: r.txns, _c: r.color
|
||
})), { max: rails[0] ? rails[0].amt : 1, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||
C.hbars($('#pf-cnt'), byCount.map(r => ({
|
||
label: r.name, value: r.n, color: r.color, sub: CLPk(r.amt), _txns: r.txns, _c: r.color
|
||
})), { max: byCount[0] ? byCount[0].n : 1, money: false, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||
}
|
||
|
||
// register all
|
||
D.register('balances', renderBalances);
|
||
D.register('people', renderPeople);
|
||
D.register('cards', renderCards);
|
||
D.register('rhythm', renderRhythm);
|
||
D.register('ledger', renderLedger);
|
||
D.register('quality', renderQuality);
|
||
D.register('platforms', renderPlatforms);
|
||
})();
|