← back to Zendesk Chat Analyzer

public/app.js

307 lines

/* DW Chat History Analyzer — client. Loads aggregated data.json (no secrets) and renders. */
let DATA = {chats: []}, FILTER = {country: null, missed: false, theme: null, brand: null, sentiment: null, urgency: null}, WCMODE = 'titles', charts = {}, sortKey = 'ts', sortDir = -1, network = null;

const STOP = new Set(("the a an and or of to in for on with is are be it this that you your we our i "+
"at as by from he she they them his her hi hello hey thanks thank please great day designer wallcoverings "+
"this steve samantha marcus parker customer service chat here can how what when where would like just "+
"was were will has have had do does not no yes ok okay im ive youre were about if so my me want need "+
"good morning afternoon looking help am pm us united states http https www com").split(/\s+/));

const fmtDate = t => { if(!t) return ''; const d = typeof t==='number' ? new Date(t*1000) : new Date(t); return isNaN(d) ? '' : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
const rows = () => DATA.chats.filter(c =>
  (!FILTER.country || c.country===FILTER.country) &&
  (!FILTER.missed || c.missed) &&
  (!FILTER.theme || (c.themes||[]).includes(FILTER.theme)) &&
  (!FILTER.brand || (c.brands||[]).includes(FILTER.brand)) &&
  (!FILTER.sentiment || c.sentiment===FILTER.sentiment) &&
  (!FILTER.urgency || c.urgency===FILTER.urgency));

function load(){
  fetch('data.json?_='+Date.now()).then(r=>r.json()).then(d=>{ DATA=d; boot(); })
    .catch(()=>{ document.getElementById('meta').textContent='no data.json — click Refresh'; });
}
function boot(){
  document.getElementById('meta').textContent =
    `${DATA.count} chats · generated ${fmtDate(DATA.generated_at)}`;
  loadCols();
  renderAll();
}
function renderAll(){ renderFilter(); kpis(); country(); type(); themeChart(); brandChart(); heatmap(); mindmap(); platform(); agents(); timeline(); wc(); table(); }

function renderFilter(){
  const fb=document.getElementById('filterbar');
  const parts=[];
  if(FILTER.country) parts.push(`country = <b>${FILTER.country}</b>`);
  if(FILTER.missed) parts.push(`<b>missed / unanswered</b> chats only 📞`);
  if(FILTER.theme) parts.push(`theme = <b>${FILTER.theme}</b>`);
  if(FILTER.brand) parts.push(`brand = <b>${FILTER.brand}</b>`);
  if(FILTER.sentiment) parts.push(`sentiment = <b>${FILTER.sentiment}</b>`);
  if(FILTER.urgency) parts.push(`urgency = <b>${FILTER.urgency}</b>`);
  const neg=DATA.chats.filter(c=>c.sentiment==='negative').length, urg=DATA.chats.filter(c=>c.urgency==='high').length, mis=DATA.chats.filter(c=>c.missed).length;
  fb.innerHTML = parts.length
    ? `Filtered to ${parts.join(' + ')} — <b>${rows().length}</b> chats <span class="clr" onclick="clearF()">✕ clear</span>`
    : `Showing all <b>${DATA.chats.length}</b> chats · <span class="clr" onclick="showMissed()">📞 ${mis} missed</span> · <span class="clr" onclick="setF('sentiment','negative')">😞 ${neg} unhappy</span> · <span class="clr" onclick="setF('urgency','high')">⏰ ${urg} urgent</span>`;
}
window.clearF=()=>{ FILTER={country:null,missed:false,theme:null,brand:null,sentiment:null,urgency:null}; renderAll(); };
window.showMissed=()=>{ FILTER.missed=true; sortKey='ts'; sortDir=-1; renderAll(); document.querySelector('#tbl').scrollIntoView({behavior:'smooth'}); };

function kpis(){
  const r=rows();
  const ctry=new Set(r.map(c=>c.country)).size;
  const off=r.filter(c=>c.type==='offline_msg').length;
  const missed=r.filter(c=>c.missed).length;
  const rated=r.filter(c=>c.rating==='good').length;
  const ratedBad=r.filter(c=>c.rating==='bad').length;
  const ts=r.map(c=>c.ts).filter(Boolean).sort();
  const span = ts.length? `${fmtDate(ts[0]).split(',')[0]} → ${fmtDate(ts[ts.length-1]).split(',')[0]}`:'—';
  const K=[['Chats',r.length,span,null],['Countries',ctry,'',null],['Live chats',r.length-off,'',null],
    ['Offline msgs',off,'',null],['Missed',missed,missed?'👆 click to follow up':'','showMissed()'],
    ['Rated 👍/👎',`${rated}/${ratedBad}`,'',null]];
  document.getElementById('kpis').innerHTML=K.map(([l,n,s,click])=>
    `<div class="kpi${click?' clk':''}"${click?` onclick="${click}"`:''}><div class="n">${n}</div><div class="l">${l}</div><div class="s">${s||'&nbsp;'}</div></div>`).join('');
}

function mkChart(id,cfg){ if(charts[id])charts[id].destroy(); charts[id]=new Chart(document.getElementById(id),cfg); }
const AXIS={ticks:{color:'#9aa3b2'},grid:{color:'#262b36'}};

function country(){
  const m={}; rows().forEach(c=>m[c.country]=(m[c.country]||0)+1);
  const e=Object.entries(m).sort((a,b)=>b[1]-a[1]).slice(0,15);
  mkChart('ctryChart',{type:'bar',data:{labels:e.map(x=>x[0]),
    datasets:[{data:e.map(x=>x[1]),backgroundColor:e.map(x=>x[0]==='United States'?'#4ade80':'#6ea8fe')}]},
    options:{plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS},
      onClick:(ev,el)=>{ if(el[0]){ FILTER.country=e[el[0].index][0]; renderAll(); } }}});
}
function type(){
  const r=rows(),off=r.filter(c=>c.type==='offline_msg').length;
  mkChart('typeChart',{type:'doughnut',data:{labels:['Live chat','Offline message'],
    datasets:[{data:[r.length-off,off],backgroundColor:['#6ea8fe','#fbbf24']}]},
    options:{plugins:{legend:{position:'bottom',labels:{color:'#9aa3b2'}}}}});
}
function platform(){
  const m={}; rows().forEach(c=>{const p=c.platform||'Unknown';m[p]=(m[p]||0)+1;});
  const e=Object.entries(m).sort((a,b)=>b[1]-a[1]);
  mkChart('platChart',{type:'bar',data:{labels:e.map(x=>x[0]),datasets:[{data:e.map(x=>x[1]),backgroundColor:'#a78bfa'}]},
    options:{indexAxis:'y',plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS}}});
}
function agents(){
  const m={}; rows().forEach(c=>(c.agents||[]).forEach(a=>m[a]=(m[a]||0)+1));
  const e=Object.entries(m).sort((a,b)=>b[1]-a[1]).slice(0,8);
  mkChart('agentChart',{type:'bar',data:{labels:e.map(x=>x[0]),datasets:[{data:e.map(x=>x[1]),backgroundColor:'#f472b6'}]},
    options:{indexAxis:'y',plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS}}});
}
function timeline(){
  const m={}; rows().forEach(c=>{ if(!c.ts)return; const d=new Date(c.ts); if(isNaN(d))return; const k=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); m[k]=(m[k]||0)+1;});
  const e=Object.entries(m).sort();
  mkChart('timeChart',{type:'line',data:{labels:e.map(x=>x[0]),datasets:[{data:e.map(x=>x[1]),borderColor:'#6ea8fe',backgroundColor:'rgba(110,168,254,.15)',fill:true,tension:.3}]},
    options:{plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS}}});
}

function themeChart(){
  const m={}; rows().forEach(c=>(c.themes||[]).forEach(t=>m[t]=(m[t]||0)+1));
  const e=Object.entries(m).sort((a,b)=>b[1]-a[1]);
  mkChart('themeChart',{type:'bar',data:{labels:e.map(x=>x[0]),datasets:[{data:e.map(x=>x[1]),
    backgroundColor:e.map(x=>x[0].includes('complaint')?'#f87171':x[0].includes('discontinu')?'#fbbf24':'#6ea8fe')}]},
    options:{indexAxis:'y',plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS},
      onClick:(ev,el)=>{ if(el[0]){ FILTER.theme=e[el[0].index][0]; renderAll(); } }}});
}
function brandChart(){
  const m={}; rows().forEach(c=>(c.brands||[]).forEach(b=>m[b]=(m[b]||0)+1));
  const e=Object.entries(m).sort((a,b)=>b[1]-a[1]).slice(0,14);
  mkChart('brandChart',{type:'bar',data:{labels:e.map(x=>x[0]),datasets:[{data:e.map(x=>x[1]),backgroundColor:'#4ade80'}]},
    options:{indexAxis:'y',plugins:{legend:{display:false}},scales:{x:AXIS,y:AXIS},
      onClick:(ev,el)=>{ if(el[0]){ FILTER.brand=e[el[0].index][0]; renderAll(); } }}});
}
const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
function heatmap(){
  const g=Array.from({length:7},()=>new Array(24).fill(0)); let max=0,tot=0;
  rows().forEach(c=>{ if(!c.ts)return; const d=new Date(c.ts); if(isNaN(d))return; const v=++g[d.getDay()][d.getHours()]; if(v>max)max=v; tot++; });
  let html='<div class="hm"><div></div>'+Array.from({length:24},(_,h)=>`<div class="hh">${h%3===0?h:''}</div>`).join('');
  for(let r=0;r<7;r++){ html+=`<div class="lbl">${DOW[r]}</div>`;
    for(let h=0;h<24;h++){ const v=g[r][h]; const a=max?v/max:0;
      html+=`<div class="cell" title="${DOW[r]} ${h}:00 — ${v} chats" style="background:${v?`rgba(110,168,254,${.15+a*.85})`:'#11141a'}"></div>`; } }
  html+='</div>';
  document.getElementById('heatmap').innerHTML=html;
  // peak
  let pk=[0,0,0]; for(let r=0;r<7;r++)for(let h=0;h<24;h++)if(g[r][h]>pk[2])pk=[r,h,g[r][h]];
  document.getElementById('heatNote').textContent = tot?`Busiest: ${DOW[pk[0]]} ${pk[1]}:00 (${pk[2]} chats) · times in your browser timezone`:'no timestamped chats in filter';
}
function mindmap(){
  const el=document.getElementById('mindmap');
  const r=rows();
  const tc={},bc={},edges={};
  r.forEach(c=>{ const th=c.themes||[],br=(c.brands||[]).slice(0,3);
    th.forEach(t=>tc[t]=(tc[t]||0)+1); br.forEach(b=>bc[b]=(bc[b]||0)+1);
    th.forEach(t=>br.forEach(b=>{ const k=t+'||'+b; edges[k]=(edges[k]||0)+1; })); });
  const topB=Object.entries(bc).sort((a,b)=>b[1]-a[1]).slice(0,12).map(x=>x[0]);
  const nodes=[], seen=new Set();
  Object.entries(tc).forEach(([t,n])=>{ nodes.push({id:'t:'+t,label:t,shape:'box',color:{background:'#1d2b4a',border:'#6ea8fe'},font:{color:'#e8eaf0',size:14},value:n}); seen.add('t:'+t); });
  topB.forEach(b=>{ nodes.push({id:'b:'+b,label:b,color:{background:'#14331f',border:'#4ade80'},font:{color:'#cdeacc',size:13},value:bc[b]}); seen.add('b:'+b); });
  const eds=[]; Object.entries(edges).forEach(([k,n])=>{ const [t,b]=k.split('||'); if(seen.has('t:'+t)&&seen.has('b:'+b)&&n>0) eds.push({from:'t:'+t,to:'b:'+b,value:n,color:{color:'rgba(154,163,178,.35)'}}); });
  if(network){ network.destroy(); network=null; }
  if(!nodes.length){ el.innerHTML='<div class="mut" style="padding:20px">no theme/brand links in this filter</div>'; return; }
  network=new vis.Network(el,{nodes:new vis.DataSet(nodes),edges:new vis.DataSet(eds)},{
    nodes:{scaling:{min:10,max:40}},edges:{scaling:{min:1,max:8},smooth:false},
    physics:{stabilization:true,barnesHut:{gravitationalConstant:-4000,springLength:120}},
    interaction:{hover:true,tooltipDelay:120}});
  network.on('click',p=>{ if(p.nodes[0]){ const id=p.nodes[0]; if(id.startsWith('t:')){FILTER.theme=id.slice(2);renderAll();} else if(id.startsWith('b:')){FILTER.brand=id.slice(2);renderAll();} } });
}

const WCTABS=[['titles','🛍️ Products browsed'],['msgs','💬 Visitor messages'],['tags','🏷️ Tags / locations'],['cities','📍 Cities'],['search','🔎 Search terms']];
function wc(){
  document.getElementById('wcTabs').innerHTML=WCTABS.map(([k,l])=>
    `<span class="tab ${k===WCMODE?'on':''}" onclick="setWC('${k}')">${l}</span>`).join('');
  const r=rows(); let words=[];
  if(WCMODE==='titles') r.forEach(c=>(c.titles||[]).forEach(t=>words.push(...tok(t))));
  else if(WCMODE==='msgs') r.forEach(c=>(c.visitor_msgs||[]).forEach(m=>words.push(...tok(m))));
  else if(WCMODE==='tags') r.forEach(c=>(c.tags||[]).forEach(t=>words.push(t.toLowerCase())));
  else if(WCMODE==='cities') r.forEach(c=>{ if(c.city)words.push(c.city.toLowerCase()); });
  else if(WCMODE==='search') r.forEach(c=>{ if(c.search_terms)words.push(...tok(c.search_terms)); });
  const freq={}; words.forEach(w=>{ if(w&&w.length>2&&!STOP.has(w))freq[w]=(freq[w]||0)+1; });
  const list=Object.entries(freq).sort((a,b)=>b[1]-a[1]).slice(0,120);
  const cv=document.getElementById('wc');
  document.getElementById('wcNote').textContent = list.length? `${list.length} distinct terms · top: ${list.slice(0,5).map(x=>x[0]).join(', ')}` : 'no text for this view in the current filter';
  if(!list.length){ const ctx=cv.getContext('2d'); ctx.clearRect(0,0,cv.width,cv.height); return; }
  const max=list[0][1];
  WordCloud(cv,{list:list.map(([w,n])=>[w,10+Math.round(48*n/max)]),
    backgroundColor:'#181b22',color:()=>['#6ea8fe','#4ade80','#fbbf24','#f472b6','#a78bfa'][Math.floor(Math.random()*5)],
    fontFamily:'-apple-system,Segoe UI,Roboto',rotateRatio:.35,gridSize:8,drawOutOfBound:false});
}
window.setWC=k=>{ WCMODE=k; wc(); };
function tok(s){ return (s||'').toLowerCase().replace(/[^a-z0-9\s']/g,' ').split(/\s+/).filter(Boolean); }

const chips=a=>(a||[]).map(t=>`<span class="chip">${t}</span>`).join('');
const dur=s=>{ s=+s||0; return s?`${Math.floor(s/60)}m ${s%60}s`:''; };
// full column set: key,label,getter,plainGetter(for title/search/sort)
const COLDEFS=[
  {k:'ts',l:'When',g:c=>fmtDate(c.ts),p:c=>c.ts||''},
  {k:'country',l:'Country',g:c=>c.country&&c.country!=='Unknown'?`<span class="pill" onclick="setF('country',this.dataset.v)" data-v="${(c.country||'').replace(/"/g,'')}">${c.country}</span>`:c.country,p:c=>c.country},
  {k:'city',l:'City',g:c=>c.city,p:c=>c.city},
  {k:'region',l:'Region',g:c=>c.region,p:c=>c.region},
  {k:'platform',l:'Platform',g:c=>c.platform,p:c=>c.platform},
  {k:'browser',l:'Browser',g:c=>c.browser,p:c=>c.browser},
  {k:'type',l:'Type',g:c=>c.type,p:c=>c.type},
  {k:'msg_count',l:'Msgs',g:c=>c.msg_count,p:c=>c.msg_count},
  {k:'duration',l:'Duration',g:c=>dur(c.duration),p:c=>+c.duration||0},
  {k:'agents',l:'Agent',g:c=>(c.agents||[]).join(', '),p:c=>(c.agents||[]).join(' ')},
  {k:'rating',l:'Rating',g:c=>c.rating==='good'?'👍':c.rating==='bad'?'👎':'',p:c=>c.rating},
  {k:'sentiment',l:'Sentiment',g:c=>c.sentiment==='negative'?'😞 neg':c.sentiment==='positive'?'😊 pos':c.sentiment?'😐':'',p:c=>c.sentiment||''},
  {k:'urgency',l:'Urgency',g:c=>c.urgency==='high'?'⏰ HIGH':'',p:c=>c.urgency||''},
  {k:'themes',l:'Themes',g:c=>chips(c.themes),p:c=>(c.themes||[]).join(' ')},
  {k:'brands',l:'Brands',g:c=>chips(c.brands),p:c=>(c.brands||[]).join(' ')},
  {k:'titles',l:'Browsed',g:c=>(c.titles||[])[0]||'',p:c=>(c.titles||[]).join(' ')},
  {k:'msg',l:'What they said',g:c=>(c.visitor_msgs||[]).join(' · ').slice(0,140),p:c=>(c.visitor_msgs||[]).join(' ')},
  {k:'search_terms',l:'Search',g:c=>c.search_terms,p:c=>c.search_terms},
  {k:'tags',l:'Tags',g:c=>chips(c.tags),p:c=>(c.tags||[]).join(' ')},
  {k:'missed',l:'Missed',g:c=>c.missed?'📞':'',p:c=>c.missed?'missed':''},
  {k:'ticket_id',l:'Ticket',g:c=>c.ticket_id,p:c=>c.ticket_id},
];
const CMAP=Object.fromEntries(COLDEFS.map(d=>[d.k,d]));
const DEF_ORDER=COLDEFS.map(d=>d.k);
const DEF_HIDE=['region','browser','duration','search_terms','ticket_id','msg']; // sensible tight default
const TABLE_ROW_CAP = 500;
let colOrder, colHidden;
function loadCols(){
  try{ const s=JSON.parse(localStorage.getItem('zca_cols')||'null'); colOrder=(s&&s.order||DEF_ORDER).filter(k=>CMAP[k]); DEF_ORDER.forEach(k=>{if(!colOrder.includes(k))colOrder.push(k);}); colHidden=new Set(s&&s.hidden||DEF_HIDE); }
  catch(e){ colOrder=DEF_ORDER.slice(); colHidden=new Set(DEF_HIDE); }
  const sv=localStorage.getItem('zca_sort'); if(sv){ const[k,d]=sv.split(':'); if(CMAP[k]){sortKey=k;sortDir=+d;} }
}
function saveCols(){ localStorage.setItem('zca_cols',JSON.stringify({order:colOrder,hidden:[...colHidden]})); }
const visCols=()=>colOrder.filter(k=>!colHidden.has(k));
window.setF=(dim,v)=>{ FILTER[dim]=v; renderAll(); };

/** Returns filtered, searched, sorted rows — shared by table() and exportCsv. */
function filteredSortedRows(){
  let r=rows().slice();
  const q=document.getElementById('search').value.toLowerCase().split(/\s+/).filter(Boolean);
  if(q.length) r=r.filter(c=>{ const blob=COLDEFS.map(d=>d.p(c)).join(' ').toLowerCase(); return q.every(w=>blob.includes(w)); });
  const sd=CMAP[sortKey]||CMAP.ts;
  r.sort((a,b)=>{ let x=sd.p(a),y=sd.p(b); if(typeof x==='string')x=x.toLowerCase(); if(typeof y==='string')y=y.toLowerCase(); return (x>y?1:x<y?-1:0)*sortDir; });
  return r;
}
function table(){
  const r=filteredSortedRows();
  const cols=visCols();
  document.querySelector('#tbl thead').innerHTML='<tr>'+cols.map(k=>{ const d=CMAP[k];
    return `<th class="drag" draggable="true" data-k="${k}" title="drag to reorder / click to sort">`+
      `<span class="grip">⋮⋮</span>${d.l}${sortKey===k?(sortDir>0?' ▲':' ▼'):''}</th>`; }).join('')+'</tr>';
  document.querySelector('#tbl tbody').innerHTML=r.slice(0,TABLE_ROW_CAP).map(c=>'<tr>'+cols.map(k=>{
    const d=CMAP[k]; const v=d.g(c); const t=String(d.p(c)).replace(/"/g,'');
    return `<td title="${t}">${(v===''||v==null)?'<span class=mut>—</span>':v}</td>`;}).join('')+'</tr>').join('');
  document.getElementById('tableCount').textContent=`${r.length} rows · ${cols.length}/${COLDEFS.length} cols`;
  wireHeaders();
}
function wireHeaders(){
  const ths=[...document.querySelectorAll('#tbl thead th')];
  ths.forEach(th=>{
    th.onclick=e=>{ if(th._dragged){th._dragged=false;return;} const k=th.dataset.k; if(sortKey===k)sortDir*=-1; else{sortKey=k;sortDir=1;} localStorage.setItem('zca_sort',sortKey+':'+sortDir); table(); };
    th.ondragstart=e=>{ th.classList.add('dragging'); e.dataTransfer.setData('text/plain',th.dataset.k); };
    th.ondragend=e=>{ th.classList.remove('dragging'); th._dragged=true; setTimeout(()=>th._dragged=false,50); };
    th.ondragover=e=>{ e.preventDefault(); th.classList.add('dragover'); };
    th.ondragleave=e=>{ th.classList.remove('dragover'); };
    th.ondrop=e=>{ e.preventDefault(); th.classList.remove('dragover'); const from=e.dataTransfer.getData('text/plain'),to=th.dataset.k;
      if(from&&to&&from!==to){ const o=colOrder.filter(k=>k!==from); o.splice(o.indexOf(to),0,from); colOrder=o; saveCols(); table(); } };
  });
}
function buildColMenu(){
  const pop=document.getElementById('colPop');
  pop.innerHTML=colOrder.map(k=>{ const d=CMAP[k];
    return `<label><input type="checkbox" data-k="${k}" ${colHidden.has(k)?'':'checked'}> ${d.l}</label>`; }).join('');
  pop.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{ const k=cb.dataset.k; if(cb.checked)colHidden.delete(k); else colHidden.add(k); saveCols(); table(); });
}
document.getElementById('colBtn').onclick=e=>{ e.stopPropagation(); buildColMenu(); document.getElementById('colPop').classList.toggle('on'); };
document.addEventListener('click',()=>document.getElementById('colPop').classList.remove('on'));
document.getElementById('colPop').onclick=e=>e.stopPropagation();
document.getElementById('resetCols').onclick=()=>{ colOrder=DEF_ORDER.slice(); colHidden=new Set(DEF_HIDE); sortKey='ts';sortDir=-1; localStorage.removeItem('zca_cols');localStorage.removeItem('zca_sort'); table(); };
document.getElementById('exportCsv').onclick=()=>{
  const r=filteredSortedRows(); // export honors active search + sort, no row cap
  const cols=visCols(), esc=v=>{ v=String(v==null?'':v); return /[",\n]/.test(v)?'"'+v.replace(/"/g,'""')+'"':v; };
  const csv=[cols.map(k=>esc(CMAP[k].l)).join(',')].concat(r.map(c=>cols.map(k=>esc(CMAP[k].p(c))).join(','))).join('\r\n');
  const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
  const f=[FILTER.theme,FILTER.brand,FILTER.country,FILTER.missed?'missed':''].filter(Boolean).join('-')||'all';
  a.download=`dw-chats-${f}-${r.length}rows.csv`; a.click(); URL.revokeObjectURL(a.href);
};
document.getElementById('search').oninput=table;
document.getElementById('density').oninput=e=>{ document.querySelectorAll('#tbl td,#tbl th').forEach(td=>td.style.padding=`${e.target.value*.7}px 9px`); };

document.getElementById('refresh').onclick=function(){
  this.textContent='↻ pulling…'; this.disabled=true;
  fetch('/api/refresh',{method:'POST'}).then(r=>r.json()).then(d=>{ location.reload(); })
    .catch(()=>{ this.textContent='↻ refresh failed (run pull.py)'; this.disabled=false; });
};

/* ---------- Live recent-activity feed ---------- */
const flag = cc => (cc && cc.length===2 && /^[A-Z]{2}$/.test(cc)) ? String.fromCodePoint(...[...cc].map(c=>0x1F1E6+c.charCodeAt(0)-65)) : '🌐';
const esc = s => (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
function ago(ts, now){ try{ const t=Math.floor(new Date(ts).getTime()/1000); const s=now-t; if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }catch(e){ return ''; } }
let livePrevIds = new Set(), liveFirst = true;
function pollLive(){
  const h = document.getElementById('liveWindow').value;
  fetch('/api/recent?hours='+h+'&_='+Date.now()).then(r=>r.json()).then(d=>{
    const now = d.now || Math.floor(Date.now()/1000);
    const ch = d.chats || [];
    document.getElementById('liveMeta').textContent = `${d.count||ch.length} chats · updated ${new Date().toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit',second:'2-digit'})} · auto-refresh 30s`;
    const feed = document.getElementById('liveFeed');
    if(!ch.length){ feed.innerHTML = `<div class="mut" style="padding:14px">No chats in the last ${h}h. Newest activity will appear here live.</div>`; return; }
    feed.innerHTML = '<div class="feed">'+ch.map(c=>{
      const fresh = !liveFirst && !livePrevIds.has(c.id);
      const agent = (c.agents||[]).join(', ');
      return `<div class="fitem${c.missed?' miss':''}${fresh?' fresh':''}">
        <div class="ago">${ago(c.ts,now)}</div>
        <div style="flex:1;min-width:0">
          <div><span class="who">${flag(c.country_code)} ${esc(c.city||c.country)}</span>
            <span class="loc"> · ${esc(c.country)} · ${esc(c.platform||'')} · ${c.type==='offline_msg'?'offline msg':'chat'}</span>
            ${c.missed?'<span class="badge m">MISSED</span>':(agent?`<span class="badge">${esc(agent)}</span>`:'')}</div>
          ${c.msg?`<div class="msg">“${esc(c.msg)}”</div>`:''}
          ${(c.browsed&&c.browsed[0])?`<div class="brows">🛍️ ${esc(c.browsed[0])}</div>`:''}
        </div></div>`;
    }).join('')+'</div>';
    livePrevIds = new Set(ch.map(c=>c.id)); liveFirst = false;
  }).catch(()=>{ document.getElementById('liveMeta').textContent='live feed unavailable'; });
}
document.getElementById('liveWindow').onchange=()=>{ liveFirst=true; pollLive(); };
pollLive();
setInterval(pollLive, 30000);

load();