Twemoji category icons as logo fallback

For transactions with no brand match, show the spending category emoji
(Twitter Twemoji SVG via jsDelivr) instead of a bare initial:
  Groceries 🛒  Food 🍔  Fuel   Health 💊  Subscriptions 💻
  Shopping 🛍  Utilities 💡  Cash 🏧  Debt 💳  Wallets 📱  Fees 🏦
Income/transfer rows use flow-type icons (💰 income, 🔁 self-transfer).
Resolution chain: Brandfetch brand logo -> category Twemoji -> coloured
initial. Brand logo onerror also falls back to the category emoji.
Call sites pass {category} (merchants/breakdown) or {flowType} (ledger).
This commit is contained in:
Kavi 2026-06-02 14:44:20 -04:00
parent e0f2252124
commit e4e8f20555
4 changed files with 43 additions and 11 deletions

View File

@ -220,3 +220,5 @@
.mrow .m-name { display: flex; align-items: center; gap: 9px; } .mrow .m-name { display: flex; align-items: center; gap: 9px; }
.bk-row-desc.with-logo { display: flex; align-items: center; gap: 8px; } .bk-row-desc.with-logo { display: flex; align-items: center; gap: 8px; }
.lx-desc .lx-logo-wrap { display: inline-flex; align-items: center; gap: 8px; } .lx-desc .lx-logo-wrap { display: inline-flex; align-items: center; gap: 8px; }
.mlogo-cat { display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: rgba(255,255,255,0.06); vertical-align: middle; flex: none; }
.mlogo-cat img { display: block; }

View File

@ -158,7 +158,7 @@
merch.forEach(([name, d], i) => { merch.forEach(([name, d], i) => {
const cat = window.MT.spendCategory(d.txns[0].description); const cat = window.MT.spendCategory(d.txns[0].description);
const row = document.createElement('div'); row.className = 'mrow'; const row = document.createElement('div'); row.className = 'mrow';
const logo = window.MTlogo ? window.MTlogo.html(name, 22) : ''; 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.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('mousemove', e => window.MTtip.rows(e, name, col.spend(), d.txns));
row.addEventListener('mouseleave', () => window.MTtip.hide()); row.addEventListener('mouseleave', () => window.MTtip.hide());
@ -180,7 +180,7 @@
section.className = 'bk-section'; section.className = 'bk-section';
const rowsHtml = descs.slice(0, 30).map(([desc, d]) => { const rowsHtml = descs.slice(0, 30).map(([desc, d]) => {
const cpct = Math.round(d.value / catData.value * 100); const cpct = Math.round(d.value / catData.value * 100);
const blogo = window.MTlogo ? window.MTlogo.html(desc, 18) : ""; const blogo = window.MTlogo ? window.MTlogo.html(desc, 18, {category: catName}) : "";
return `<div class="bk-row"> return `<div class="bk-row">
<span class="bk-row-desc with-logo">${blogo}<span>${esc(desc)}</span></span> <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-cnt">${d.txns.length}×</span>

View File

@ -285,7 +285,7 @@
const shown = rows.slice(0, st.limit); const shown = rows.slice(0, st.limit);
$('#lx-rows').innerHTML = shown.map(t => ` $('#lx-rows').innerHTML = shown.map(t => `
<tr><td class="lx-date">${t.date}</td> <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) : ''}<span>${esc(t.description || t.counterparty || '—')}</span></span></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 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><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(''); <td class="r lx-amt ${t.direction === 'credit' ? 'cr' : 'db'}">${t.direction === 'credit' ? '+' : ''}${CLP(t.amount)}</td></tr>`).join('');

View File

@ -76,16 +76,46 @@
const ch=(m||desc||'?').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase(); const ch=(m||desc||'?').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase();
return ch||'?'; return ch||'?';
} }
// returns an HTML string: a brand logo <img> with initial fallback, or just the initial chip // ---- category fallback via Twemoji (jdecked fork) ----
function html(desc, size){ const TW = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/';
const CATEGORY_ICON = {
'Debt & loans':'1f4b3','Cash withdrawals':'1f3e7','Utilities & bills':'1f4a1',
'Groceries':'1f6d2','Food & dining':'1f354','Fuel & transport':'26fd',
'Health & pharmacy':'1f48a','Subscriptions & web':'1f4bb','Shopping & retail':'1f6cd',
'Government & docs':'1f3db','Wallets & online':'1f4f1','Fees & interest':'1f3e6',
'Other purchases':'1f9fe'
};
const FLOW_ICON = { income:'1f4b0', inter_in:'1f4b8', inter_out:'1f4b8',
self_transfer:'1f501', card_payment:'1f4b3', credit_line:'1f3e6', fee:'1f3e6' };
function emojiImg(cp, size){
const inner = Math.round(size*0.62);
return '<span class="mlogo mlogo-cat" style="width:'+size+'px;height:'+size+'px">'+
'<img src="'+TW+cp+'.svg" width="'+inner+'" height="'+inner+'" loading="lazy" alt=""></span>';
}
function categoryFor(desc, opts){
opts = opts||{};
if(opts.flowType && opts.flowType!=='expense' && FLOW_ICON[opts.flowType]) return FLOW_ICON[opts.flowType];
let cat = opts.category;
if(!cat && window.MT && window.MT.spendCategory) cat = window.MT.spendCategory(desc);
if(cat && CATEGORY_ICON[cat]) return CATEGORY_ICON[cat];
return null;
}
function initChip(desc, size){
return '<span class="mlogo mlogo-fb" style="width:'+size+'px;height:'+size+'px;background:'+colorOf(desc||'')+'">'+esc(initialOf(desc))+'</span>';
}
// returns an HTML string: brand logo, else category emoji, else coloured initial.
function html(desc, size, opts){
size = size||22; size = size||22;
const b = brandOf(desc); const b = brandOf(desc);
const key = b ? b.domain : (desc||''); if(b){
const init = b ? b.name.charAt(0).toUpperCase() : initialOf(desc); const url='https://cdn.brandfetch.io/'+b.domain+'/w/'+(size*2)+'/h/'+(size*2)+'?c='+CLIENT_ID;
const fb = '<span class="mlogo mlogo-fb" style="width:'+size+'px;height:'+size+'px;background:'+colorOf(key)+'">'+esc(init)+'</span>'; const cp = categoryFor(desc, opts);
if(!b) return fb; const fb = cp ? emojiImg(cp, size) : initChip(desc, size);
const url='https://cdn.brandfetch.io/'+b.domain+'/w/'+(size*2)+'/h/'+(size*2)+'?c='+CLIENT_ID; return '<img class="mlogo" src="'+url+'" width="'+size+'" height="'+size+'" loading="lazy" alt="'+esc(b.name)+'" title="'+esc(b.name)+'" data-fb="'+esc(fb)+'" onerror="this.outerHTML=this.getAttribute(&quot;data-fb&quot;)">';
return '<img class="mlogo" src="'+url+'" width="'+size+'" height="'+size+'" loading="lazy" alt="'+esc(b.name)+'" title="'+esc(b.name)+'" data-fb="'+esc(fb)+'" onerror="this.outerHTML=this.getAttribute(&quot;data-fb&quot;)">'; }
const cp = categoryFor(desc, opts);
if(cp) return emojiImg(cp, size);
return initChip(desc, size);
} }
window.MTlogo = { html, brandOf }; window.MTlogo = { html, brandOf };
})(); })();