[object Object]

← 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

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||'&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}}});
+}
+
+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 →