← back to Zendesk Chat Analyzer
Zendesk chat-history web viewer: KPIs, country/type/platform/agent charts, timeline, word clouds, searchable table
acdb19e014bbdc7b93bfafc811db7b37e77cee97 · 2026-08-11 07:28:00 -0700 · Steve
Files touched
A .gitignoreA public/app.jsA public/index.htmlA pull.pyA server.js
Diff
commit acdb19e014bbdc7b93bfafc811db7b37e77cee97
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 07:28:00 2026 -0700
Zendesk chat-history web viewer: KPIs, country/type/platform/agent charts, timeline, word clouds, searchable table
---
.gitignore | 6 +++
public/app.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 99 ++++++++++++++++++++++++++++++++++++++++
pull.py | 93 ++++++++++++++++++++++++++++++++++++++
server.js | 29 ++++++++++++
5 files changed, 359 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..57ec9bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+public/data.json
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..07304e7
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,132 @@
+/* DW Chat History Analyzer — client. Loads aggregated data.json (no secrets) and renders. */
+let DATA = {chats: []}, FILTER = {country: null}, WCMODE = 'titles', charts = {}, sortKey = 'ts', sortDir = -1;
+
+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 = () => FILTER.country ? DATA.chats.filter(c=>c.country===FILTER.country) : DATA.chats;
+
+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)}`;
+ renderAll();
+}
+function renderAll(){ renderFilter(); kpis(); country(); type(); platform(); agents(); timeline(); wc(); table(); }
+
+function renderFilter(){
+ const fb=document.getElementById('filterbar');
+ fb.innerHTML = FILTER.country
+ ? `Filtered to <b>${FILTER.country}</b> (${rows().length} chats) <span class="clr" onclick="clearF()">✕ clear</span>`
+ : `Showing all <b>${DATA.chats.length}</b> chats`;
+}
+window.clearF=()=>{ FILTER.country=null; renderAll(); };
+
+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],['Countries',ctry,''],['Live chats',r.length-off,''],
+ ['Offline msgs',off,''],['Missed',missed,missed?'follow-up':''],
+ ['Rated 👍/👎',`${rated}/${ratedBad}`,'']];
+ document.getElementById('kpis').innerHTML=K.map(([l,n,s])=>
+ `<div class="kpi"><div class="n">${n}</div><div class="l">${l}</div><div class="s">${s||' '}</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}}});
+}
+
+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 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('')]];
+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(); };
+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('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; });
+};
+load();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2fe7a03
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,99 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<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>
+<style>
+:root{--bg:#0f1115;--card:#181b22;--line:#262b36;--ink:#e8eaf0;--dim:#9aa3b2;--acc:#6ea8fe;--good:#4ade80;--warn:#fbbf24;--bad:#f87171}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+header{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:16px;position:sticky;top:0;background:var(--bg);z-index:5}
+header h1{font-size:18px;margin:0;font-weight:700}
+header .meta{color:var(--dim);font-size:12px}
+header .sp{flex:1}
+button{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13px}
+button:hover{border-color:var(--acc)}
+.wrap{padding:20px 24px;max-width:1400px;margin:0 auto}
+.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
+.kpi{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
+.kpi .n{font-size:26px;font-weight:800}
+.kpi .l{color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:.04em}
+.kpi .s{font-size:11px;color:var(--dim);margin-top:2px}
+.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;min-width:0}
+.card h3{margin:0 0 12px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:.04em;font-weight:700}
+.c6{grid-column:span 6}.c4{grid-column:span 4}.c8{grid-column:span 8}.c12{grid-column:span 12}
+@media(max-width:900px){.c6,.c4,.c8{grid-column:span 12}}
+canvas{max-width:100%}
+.wc{width:100%;height:300px;display:block}
+.tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}
+.tab{padding:5px 11px;border-radius:20px;background:#11141a;border:1px solid var(--line);cursor:pointer;font-size:12px;color:var(--dim)}
+.tab.on{background:var(--acc);color:#0b0d12;border-color:var(--acc);font-weight:700}
+.filterbar{margin:0 0 14px;color:var(--dim);font-size:13px;min-height:20px}
+.filterbar b{color:var(--ink)}
+.filterbar .clr{color:var(--acc);cursor:pointer;margin-left:8px}
+table{width:100%;border-collapse:collapse;font-size:12.5px}
+th,td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px}
+th{color:var(--dim);cursor:pointer;user-select:none;position:sticky;top:0;background:var(--card)}
+th:hover{color:var(--ink)}
+tr:hover td{background:#1d222c}
+.tablewrap{max-height:460px;overflow:auto}
+.chip{display:inline-block;background:#11141a;border:1px solid var(--line);border-radius:10px;padding:1px 7px;font-size:11px;color:var(--dim);margin:1px}
+.pill{cursor:pointer}
+.pill:hover{color:var(--acc)}
+.controls{display:flex;gap:10px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
+.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)}
+a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
+</style>
+</head>
+<body>
+<header>
+ <h1>💬 DW Chat History Analyzer</h1>
+ <span class="meta" id="meta">loading…</span>
+ <span class="sp"></span>
+ <button id="refresh">↻ Refresh from Zendesk</button>
+</header>
+<div class="wrap">
+ <div class="filterbar" id="filterbar"></div>
+ <div class="kpis" id="kpis"></div>
+
+ <div class="grid">
+ <div class="card c8">
+ <h3>Chats by country — click a bar to filter</h3>
+ <canvas id="ctryChart" height="120"></canvas>
+ </div>
+ <div class="card c4">
+ <h3>Chat vs offline message</h3>
+ <canvas id="typeChart" height="150"></canvas>
+ </div>
+
+ <div class="card c12">
+ <h3>Word clouds</h3>
+ <div class="tabs" id="wcTabs"></div>
+ <canvas id="wc" class="wc"></canvas>
+ <div class="mut" id="wcNote" style="font-size:12px"></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>
+
+ <div class="card c12">
+ <h3>Chats</h3>
+ <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>
+ <span class="mut" id="tableCount"></span>
+ </div>
+ <div class="tablewrap"><table id="tbl"><thead></thead><tbody></tbody></table></div>
+ </div>
+ </div>
+</div>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/pull.py b/pull.py
new file mode 100644
index 0000000..97d1789
--- /dev/null
+++ b/pull.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Pull Zendesk Chat history via the REST API and emit public/data.json.
+Token is read server-side from secrets-manager/.env and NEVER written to output."""
+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"))
+
+
+def token():
+ for line in open(ENV):
+ if line.startswith("ZENDESK_CHAT_ACCESS_TOKEN="):
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
+ raise SystemExit("no ZENDESK_CHAT_ACCESS_TOKEN in .env")
+
+
+def fetch(url, tok):
+ req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok})
+ with urllib.request.urlopen(req, timeout=45) as r:
+ return json.load(r)
+
+
+def clean_title(t):
+ if not t:
+ return ""
+ # strip the boilerplate site suffix from product page titles
+ t = re.split(r"[–\-|]\s*Designer Wallcoverings", t)[0]
+ t = re.sub(r"\s+", " ", t).strip()
+ return t
+
+
+def main():
+ tok = token()
+ url = BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": START})
+ rows, pages = [], 0
+ while url and pages < MAX_PAGES:
+ d = fetch(url, tok)
+ pages += 1
+ chats = d.get("chats", [])
+ for c in chats:
+ 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
+ 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())]
+ titles = [clean_title(w.get("title")) for w in (c.get("webpath") or []) if w.get("title")]
+ rows.append({
+ "id": c.get("id"),
+ "ts": c.get("timestamp"),
+ "type": c.get("type"),
+ "country_code": s.get("country_code") or "??",
+ "country": s.get("country_name") or "Unknown",
+ "city": s.get("city") or "",
+ "region": s.get("region") or "",
+ "platform": s.get("platform") or "",
+ "browser": s.get("browser") or "",
+ "agents": c.get("agent_names") or [],
+ "duration": c.get("duration") or 0,
+ "rating": c.get("rating") or "",
+ "comment": c.get("comment") or "",
+ "tags": c.get("tags") or [],
+ "search_terms": c.get("referrer_search_terms") or "",
+ "missed": bool(c.get("missed")),
+ "proactive": bool(c.get("proactive")),
+ "msg_count": len(msgs),
+ "visitor_msgs": vmsgs if vmsgs else msgs, # fallback to all if role missing
+ "titles": titles,
+ "ticket_id": c.get("zendesk_ticket_id") or "",
+ })
+ end = d.get("end_time")
+ nxt = d.get("next_page")
+ if not chats or not end:
+ break
+ url = nxt if nxt else BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": end})
+
+ payload = {
+ "generated_at": int(time.time()),
+ "count": len(rows),
+ "pages": pages,
+ "chats": rows,
+ }
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
+ json.dump(payload, open(OUT, "w"), separators=(",", ":"))
+ print("wrote %d chats (%d pages) -> %s" % (len(rows), pages, OUT))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..acfe367
--- /dev/null
+++ b/server.js
@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+// Minimal static server for the DW Chat Analyzer + /api/refresh (re-runs pull.py).
+// No external deps. Token stays server-side (pull.py reads secrets .env).
+const http = require('http'), fs = require('fs'), path = require('path');
+const { execFile } = require('child_process');
+const ROOT = path.join(__dirname, 'public');
+const PORT = process.env.PORT || 9856;
+const TYPES = { '.html':'text/html', '.js':'text/javascript', '.json':'application/json', '.css':'text/css' };
+
+http.createServer((req, res) => {
+ if (req.method === 'POST' && req.url === '/api/refresh') {
+ execFile('/usr/bin/python3', [path.join(__dirname, 'pull.py')],
+ { env: { ...process.env, START: '1770000000', MAX_PAGES: '40' } },
+ (err, so, se) => {
+ res.writeHead(err ? 500 : 200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ ok: !err, out: (so||'').trim(), err: (se||'').trim() }));
+ });
+ return;
+ }
+ let p = decodeURIComponent(req.url.split('?')[0]);
+ if (p === '/') p = '/index.html';
+ const fp = path.join(ROOT, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+ if (!fp.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
+ fs.readFile(fp, (e, buf) => {
+ if (e) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'content-type': TYPES[path.extname(fp)] || 'application/octet-stream' });
+ res.end(buf);
+ });
+}).listen(PORT, () => console.log('DW Chat Analyzer → http://localhost:' + PORT));
(oldest)
·
back to Zendesk Chat Analyzer
·
Add missed-chats drill-down (clickable Missed KPI + filter) c3ce333 →