← back to Zendesk Chat Analyzer
Add themes classification + brand extraction + theme/brand charts, hour×weekday heatmap, topic mind-map (vis-network); all click-to-filter
b875bb59c719557002aff38e0e5aa3e662bf79f8 · 2026-08-11 08:22:22 -0700 · Steve
Files touched
M public/app.jsM public/index.htmlM pull.py
Diff
commit b875bb59c719557002aff38e0e5aa3e662bf79f8
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 08:22:22 2026 -0700
Add themes classification + brand extraction + theme/brand charts, hour×weekday heatmap, topic mind-map (vis-network); all click-to-filter
---
public/app.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++----
public/index.html | 25 ++++++++++++++++++++++
pull.py | 42 +++++++++++++++++++++++++++++++-----
3 files changed, 122 insertions(+), 9 deletions(-)
diff --git a/public/app.js b/public/app.js
index fd59b2e..6cc3214 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,5 +1,5 @@
/* DW Chat History Analyzer — client. Loads aggregated data.json (no secrets) and renders. */
-let DATA = {chats: []}, FILTER = {country: null, missed: false}, WCMODE = 'titles', charts = {}, sortKey = 'ts', sortDir = -1;
+let DATA = {chats: []}, FILTER = {country: null, missed: false, theme: null, brand: 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 "+
@@ -8,7 +8,11 @@ const STOP = new Set(("the a an and or of to in for on with is are be it this th
"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));
+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)));
function load(){
fetch('data.json?_='+Date.now()).then(r=>r.json()).then(d=>{ DATA=d; boot(); })
@@ -19,18 +23,20 @@ function boot(){
`${DATA.count} chats · generated ${fmtDate(DATA.generated_at)}`;
renderAll();
}
-function renderAll(){ renderFilter(); kpis(); country(); type(); platform(); agents(); timeline(); wc(); table(); }
+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>`);
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()">📞 show ${DATA.chats.filter(c=>c.missed).length} missed chats</span>`;
}
-window.clearF=()=>{ FILTER.country=null; FILTER.missed=false; renderAll(); };
+window.clearF=()=>{ FILTER={country:null,missed:false,theme:null,brand:null}; renderAll(); };
window.showMissed=()=>{ FILTER.missed=true; sortKey='ts'; sortDir=-1; renderAll(); document.querySelector('#tbl').scrollIntoView({behavior:'smooth'}); };
function kpis(){
@@ -85,6 +91,56 @@ function timeline(){
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])=>
diff --git a/public/index.html b/public/index.html
index 5746962..d30c4a6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,6 +6,7 @@
<title>DW Chat History Analyzer</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/wordcloud@1.2.2/src/wordcloud2.min.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<style>
:root{--bg:#0f1115;--card:#181b22;--line:#262b36;--ink:#e8eaf0;--dim:#9aa3b2;--acc:#6ea8fe;--good:#4ade80;--warn:#fbbf24;--bad:#f87171}
*{box-sizing:border-box}
@@ -52,6 +53,11 @@ tr:hover td{background:#1d222c}
.controls input[type=range]{width:130px}
.controls select,.controls input[type=search]{background:#11141a;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 9px;font-size:13px}
.mut{color:var(--dim)}
+.hm{display:grid;grid-template-columns:34px repeat(24,1fr);gap:2px;align-items:center}
+.hm .lbl{font-size:9px;color:var(--dim);text-align:right;padding-right:4px}
+.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}
a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
</style>
</head>
@@ -83,6 +89,25 @@ a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
<div class="mut" id="wcNote" style="font-size:12px"></div>
</div>
+ <div class="card c6">
+ <h3>Themes — what customers chat about (click to filter)</h3>
+ <canvas id="themeChart" height="150"></canvas>
+ </div>
+ <div class="card c6">
+ <h3>Top brands / patterns asked about (click to filter)</h3>
+ <canvas id="brandChart" height="150"></canvas>
+ </div>
+
+ <div class="card c6">
+ <h3>When chats happen — hour × weekday</h3>
+ <div id="heatmap"></div>
+ <div class="mut" id="heatNote" style="font-size:11px;margin-top:6px"></div>
+ </div>
+ <div class="card c6">
+ <h3>Topic mind-map — themes ↔ brands</h3>
+ <div id="mindmap" style="width:100%;height:320px;background:#11141a;border-radius:8px"></div>
+ </div>
+
<div class="card c4"><h3>Platform</h3><canvas id="platChart" height="180"></canvas></div>
<div class="card c4"><h3>Top agents</h3><canvas id="agentChart" height="180"></canvas></div>
<div class="card c4"><h3>Chats over time</h3><canvas id="timeChart" height="180"></canvas></div>
diff --git a/pull.py b/pull.py
index 97d1789..7d777c3 100644
--- a/pull.py
+++ b/pull.py
@@ -6,8 +6,24 @@ import json, os, re, urllib.request, urllib.parse, sys, time
ENV = os.path.expanduser("~/Projects/secrets-manager/.env")
OUT = os.path.join(os.path.dirname(__file__), "public", "data.json")
BASE = "https://www.zopim.com/api/v2/incremental/chats"
-START = int(os.environ.get("START", "1770000000")) # ~ several months back
-MAX_PAGES = int(os.environ.get("MAX_PAGES", "40"))
+START = int(os.environ.get("START", "1760000000")) # wide window to capture full history
+MAX_PAGES = int(os.environ.get("MAX_PAGES", "80"))
+
+# Theme classifier — regex per theme, matched against visitor text
+THEMES = {
+ "discontinued/availability": r"discontinu|no longer|out of (stock|production)|still (available|make|carry|sell|produc|in production)|been discontinued|still (get|order|sell)|hard to find|can.?t find|in stock|backorder|availab",
+ "order status/shipping": r"where.?s my order|trackin|has.?n.?t (ship|arriv|come)|not (yet )?(received|arrived|delivered)|order status|shipping (info|status|update)|when will .* (ship|arrive|deliver)|still waiting|lead time|how long",
+ "samples": r"\bsample|swatch|memo\b|cutting|piece of",
+ "trade/pro account": r"trade account|designer discount|to the trade|pro(fessional)? account|interior designer|net price|trade price|resale|reseller",
+ "pricing/quote": r"how much|price|cost per|quote|\$|per (roll|yard|yd)|pricing|afford",
+ "installation/how-to": r"install|how (do|to) (i )?(apply|hang|paste)|paste the wall|square (feet|footage)|how many rolls|coverage|repeat|match",
+ "complaint/issue": r"wrong|damag|defect|broken|torn|missing|refund|return|cancel|terrible|awful|disappoint|bad lot|no (one|response|reply|answer)|rude|charged twice",
+}
+# Vendor/brand mentions (wallcovering + fabric houses DW carries / gets asked about)
+BRANDS = ["Kravet","Thibaut","Schumacher","Lee Jofa","Cole & Son","Phillip Jeffries","Phillipe Romano",
+ "Waverly","Brunschwig","Clarke & Clarke","Romo","Scalamandre","Osborne","Zoffany","Designers Guild",
+ "Ralph Lauren","York","Hollywood","Avant Garde","GP & J Baker","Groundworks","Pierre Frey","Arte",
+ "Maya Romanoff","Koroseal","Wolf Gordon","Gaston","Farrow","Sanderson","Morris","Rebel Walls","Astek"]
def token():
@@ -32,6 +48,16 @@ def clean_title(t):
return t
+def classify(text):
+ t = (text or "").lower()
+ return [name for name, rx in THEMES.items() if re.search(rx, t)]
+
+
+def find_brands(text):
+ t = (text or "").lower()
+ return sorted({b for b in BRANDS if b.lower() in t})
+
+
def main():
tok = token()
url = BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": START})
@@ -44,12 +70,18 @@ def main():
s = c.get("session") or {}
hist = c.get("history") or []
msgs = [h.get("msg", "") for h in hist if isinstance(h, dict) and h.get("msg")]
- # visitor msgs: sender_type == 'visitor' when present, else keep all
+ # visitor msgs: sender_type == 'visitor' (API lowercases it)
vmsgs = [h.get("msg", "") for h in hist
- if isinstance(h, dict) and h.get("msg") and
- (h.get("sender_type") == "visitor" or "visitor" in str(h.get("name", "")).lower())]
+ if isinstance(h, dict) and h.get("msg") and str(h.get("sender_type", "")).lower() == "visitor"]
+ if not vmsgs and c.get("comment"):
+ vmsgs = [c.get("comment")] # offline messages carry text in comment
titles = [clean_title(w.get("title")) for w in (c.get("webpath") or []) if w.get("title")]
+ vtext = " ".join(vmsgs)
+ themes = classify(vtext)
+ brands = find_brands(vtext + " " + " ".join(titles) + " " + " ".join(c.get("tags") or []))
rows.append({
+ "themes": themes,
+ "brands": brands,
"id": c.get("id"),
"ts": c.get("timestamp"),
"type": c.get("type"),
← c3ce333 Add missed-chats drill-down (clickable Missed KPI + filter)
·
back to Zendesk Chat Analyzer
·
Full data-grid: 19 columns, click-sort + drag-reorder + show 74e12d8 →