kua-money-trace/web/dashboard2.js

436 lines
27 KiB
JavaScript
Raw Permalink 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 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 SatSun', 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"><span class="lx-logo-wrap">${window.MTlogo ? window.MTlogo.html(t.description || t.counterparty || '', 18, {flowType: t.flow_type}) : ''}<span>${esc(t.description || t.counterparty || '—')}</span></span></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);
})();