`;
await vulKolom(el, wallets, perWallet);
if (stopMasonry) stopMasonry();
stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source');
for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card);
const btc = await btcAsset();
const openMatch = dot => openMatchPopover(dot, {
kind: 'tx', id: Number(dot.dataset.matchId),
assetId: btc ? btc.id : null,
onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)),
});
body.onclick = e => {
const collapseBtn = e.target.closest('[data-collapse]');
if (collapseBtn) {
const id = Number(collapseBtn.dataset.collapse);
const card = collapseBtn.closest('.oca-source');
const nowCollapsed = card.classList.toggle('is-collapsed');
if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id);
collapseBtn.classList.toggle('is-open', !nowCollapsed);
saveCollapsed();
return;
}
if (e.target.closest('summary') &&
e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) {
e.preventDefault();
}
const dot = e.target.closest('[data-match-trigger]');
if (dot) { openMatch(dot); return; }
const trigger = e.target.closest('[data-edit-label]');
if (trigger) openLabelEditor(el, trigger);
};
body.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const dot = e.target.closest('[data-match-trigger]');
if (dot) { e.preventDefault(); openMatch(dot); }
};
}
async function vulKolom(el, wallets, perWallet) {
const zet = (sleutel, html) => {
const e = el.querySelector(`[data-trk="${sleutel}"]`);
if (e) e.innerHTML = html;
};
const alle = await accountsWithSources();
const bronnen = await sourcesOf(alle);
const bronnenVan = id => bronnen.filter(s => s._account.id === id);
const saldi = new Map();
for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id)));
for (const a of alle) {
const cel = el.querySelector(`[data-acct-amt="${a.id}"]`);
if (cel) cel.innerHTML = amt(saldi.get(a.id));
}
const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('total', amt(total));
zet('fiat', fiatSub(total));
const { total: nTx, linked: nLinked } =
countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0);
zet('wallets', fmtInt(wallets.length));
zet('txs', fmtInt(nTx));
zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-');
const gekozen = alle.filter(a => selectedAccounts.has(a.id));
const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('selection', gekozen.length === alle.length ? ''
: `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`);
const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id)));
const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)]
.filter(Boolean).sort().at(-1)).filter(Boolean);
zet('scanned', (!gekozen.length || tijden.length < gekozen.length)
? t('tracking.scanNever')
: `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`);
}
async function walletBlock(g, txs) {
const name = g.name || t('tracking.sourceFallback');
const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id)));
const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources);
const badges = g.sources.map(sourceBadge).join('');
const collapsed = collapsedSet.has(g.id);
const isNew = txs.some(tx => newTxIds.has(tx.id));
return `
${esc(name)}
${esc(g.account ? g.account.name : '')}
${badges}
${amountHtml(sats)}
${walletMovements(txs)}
`;
}
async function walletTxs(sourceIds) {
const seen = new Map();
for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx);
return [...seen.values()];
}
function walletMovements(txs) {
if (txs.length === 0) {
return `
${t('tracking.noTxs')}
`;
}
const groups = new Map();
for (const tx of txs) {
const key = tx.blockTime ? localDate(tx.blockTime) : '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(tx);
}
const keys = [...groups.keys()].sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a < b ? 1 : (a > b ? -1 : 0);
});
return keys.map(k => {
const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k);
return `
${label}
${dayRows(groups.get(k))}
`;
}).join('');
}
function dayRows(txs) {
const byEvent = new Map();
for (const tx of txs) {
for (const ev of (eventMap.get(tx.id) || [])) {
if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] });
byEvent.get(ev.eventId).list.push(tx);
}
}
const inGroup = new Set();
for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id);
const emitted = new Set();
return txs.map(tx => {
const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2);
if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx);
return groups.map(ev => {
if (emitted.has(ev.eventId)) return '';
emitted.add(ev.eventId);
const g = byEvent.get(ev.eventId);
return eventGroupRow(g.ev, g.list);
}).join('');
}).join('');
}
function eventGroupRow(ev, list) {
const out = ev.fromAccountId === list[0].accountId;
const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn');
const arrow = out ? ARROW_UP : ARROW_DOWN;
const typeLabel = t('enums.event_type.' + ev.type);
const other = out ? ev.to_name : ev.from_name;
const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel;
const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId);
const shown = allSame ? list[0].label_name : desc;
const idsAttr = esc(JSON.stringify(list.map(x => x.id)));
const feeBlock = ev.unitFee
? `