← back to Zendesk Chat Analyzer
Full data-grid: 19 columns, click-sort + drag-reorder + show/hide toggle + Reset, persisted to localStorage (per adjustable-columns rule)
74e12d880fc1c4420fb2f58df59348e311174eb9 · 2026-08-11 08:27:55 -0700 · Steve
Files touched
M public/app.jsM public/index.html
Diff
commit 74e12d880fc1c4420fb2f58df59348e311174eb9
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 08:27:55 2026 -0700
Full data-grid: 19 columns, click-sort + drag-reorder + show/hide toggle + Reset, persisted to localStorage (per adjustable-columns rule)
---
public/app.js | 89 +++++++++++++++++++++++++++++++++++++++++++++----------
public/index.html | 15 ++++++++++
2 files changed, 89 insertions(+), 15 deletions(-)
diff --git a/public/app.js b/public/app.js
index 6cc3214..cae48d6 100644
--- a/public/app.js
+++ b/public/app.js
@@ -21,6 +21,7 @@ function load(){
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(); }
@@ -164,25 +165,83 @@ function wc(){
window.setWC=k=>{ WCMODE=k; wc(); };
function tok(s){ return (s||'').toLowerCase().replace(/[^a-z0-9\s']/g,' ').split(/\s+/).filter(Boolean); }
-const COLS=[['ts','When',c=>fmtDate(c.ts)],['country','Country',c=>c.country],['city','City',c=>c.city],
- ['platform','Platform',c=>c.platform],['type','Type',c=>c.type],['msg_count','Msgs',c=>c.msg_count],
- ['agents','Agent',c=>(c.agents||[]).join(', ')],['rating','Rating',c=>c.rating],
- ['titles','Browsed',c=>(c.titles||[])[0]||''],['tags','Tags',c=>(c.tags||[]).map(t=>`<span class="chip">${t}</span>`).join('')]];
+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:'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
+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(); };
+
function table(){
let r=rows().slice();
const q=document.getElementById('search').value.toLowerCase().split(/\s+/).filter(Boolean);
- if(q.length) r=r.filter(c=>{ const blob=[c.country,c.city,c.platform,c.type,c.rating,(c.agents||[]).join(' '),(c.titles||[]).join(' '),(c.tags||[]).join(' '),(c.visitor_msgs||[]).join(' ')].join(' ').toLowerCase(); return q.every(w=>blob.includes(w)); });
- r.sort((a,b)=>{ let x=a[sortKey],y=b[sortKey]; if(Array.isArray(x))x=x[0]||''; if(Array.isArray(y))y=y[0]||''; return (x>y?1:x<y?-1:0)*sortDir; });
- document.querySelector('#tbl thead').innerHTML='<tr>'+COLS.map(([k,l])=>`<th onclick="sortBy('${k}')">${l}${sortKey===k?(sortDir>0?' ▲':' ▼'):''}</th>`).join('')+'</tr>';
- document.querySelector('#tbl tbody').innerHTML=r.slice(0,400).map(c=>'<tr>'+COLS.map(([k,l,f])=>{
- let v=f(c);
- if(k==='country'&&c.country!=='Unknown') v=`<span class="pill" onclick="FILTER.country='${c.country.replace(/'/g,'')}';renderAll()">${v}</span>`;
- return `<td title="${String(f(c)).replace(/"/g,'')}">${v||'<span class=mut>—</span>'}</td>`;}).join('')+'</tr>').join('');
- document.getElementById('tableCount').textContent=`${r.length} rows`;
-}
-window.sortBy=k=>{ if(sortKey===k)sortDir*=-1; else{sortKey=k;sortDir=1;} table(); };
+ 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; });
+ 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,500).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('search').oninput=table;
-document.getElementById('density').oninput=e=>{ document.querySelectorAll('td,th').forEach(td=>td.style.padding=`${e.target.value*.7}px 9px`); };
+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;
diff --git a/public/index.html b/public/index.html
index d30c4a6..80a5e0e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -58,6 +58,15 @@ tr:hover td{background:#1d222c}
.hm .hh{font-size:8px;color:var(--dim);text-align:center}
.hm .cell{aspect-ratio:1;border-radius:2px;background:#11141a;min-height:12px}
.hm .cell[title]{cursor:default}
+.colmenu{position:relative;display:inline-block}
+.colpop{position:absolute;z-index:20;top:110%;left:0;background:#11141a;border:1px solid var(--line);border-radius:8px;padding:8px;min-width:190px;max-height:300px;overflow:auto;display:none;box-shadow:0 8px 24px rgba(0,0,0,.5)}
+.colpop.on{display:block}
+.colpop label{display:flex;align-items:center;gap:7px;padding:4px 6px;font-size:12.5px;cursor:pointer;border-radius:6px}
+.colpop label:hover{background:#1d222c}
+th.drag{cursor:grab}
+th.dragging{opacity:.4}
+th.dragover{border-left:2px solid var(--acc)}
+th .grip{color:var(--dim);margin-right:3px;font-size:10px}
a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
</style>
</head>
@@ -117,8 +126,14 @@ a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
<div class="controls">
<input type="search" id="search" placeholder="search all fields (space = AND)…">
<label class="mut">density <input type="range" id="density" min="4" max="14" value="8"></label>
+ <div class="colmenu">
+ <button id="colBtn">▦ Columns</button>
+ <div class="colpop" id="colPop"></div>
+ </div>
+ <button id="resetCols">↺ Reset</button>
<span class="mut" id="tableCount"></span>
</div>
+ <div class="mut" style="font-size:11px;margin:-4px 0 8px">Tip: click a header to sort · <b>drag a header to reorder</b> · use ▦ Columns to show/hide · choices are saved.</div>
<div class="tablewrap"><table id="tbl"><thead></thead><tbody></tbody></table></div>
</div>
</div>
← b875bb5 Add themes classification + brand extraction + theme/brand c
·
back to Zendesk Chat Analyzer
·
Add Live recent-activity feed: /api/recent endpoint + auto-r 72e3487 →