[object Object]

← back to Zendesk Chat Analyzer

Add Live recent-activity feed: /api/recent endpoint + auto-refreshing feed (30s) with flags, relative time, agent, message + browsed product; window selector 6h/24h/3d

72e3487146e35f8bfcf86074bfb53c9ff731eb20 · 2026-08-11 08:36:15 -0700 · Steve

Files touched

Diff

commit 72e3487146e35f8bfcf86074bfb53c9ff731eb20
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 11 08:36:15 2026 -0700

    Add Live recent-activity feed: /api/recent endpoint + auto-refreshing feed (30s) with flags, relative time, agent, message + browsed product; window selector 6h/24h/3d
---
 public/app.js     | 33 ++++++++++++++++++++++++++
 public/index.html | 24 +++++++++++++++++++
 recent.py         | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 10 ++++++++
 4 files changed, 136 insertions(+)

diff --git a/public/app.js b/public/app.js
index cae48d6..35126ed 100644
--- a/public/app.js
+++ b/public/app.js
@@ -248,4 +248,37 @@ document.getElementById('refresh').onclick=function(){
   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)) : '🌐';
+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)} ${c.city||c.country}</span>
+            <span class="loc"> · ${c.country} · ${c.platform||''} · ${c.type==='offline_msg'?'offline msg':'chat'}</span>
+            ${c.missed?'<span class="badge m">MISSED</span>':(agent?`<span class="badge">${agent}</span>`:'')}</div>
+          ${c.msg?`<div class="msg">“${c.msg}”</div>`:''}
+          ${(c.browsed&&c.browsed[0])?`<div class="brows">🛍️ ${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();
diff --git a/public/index.html b/public/index.html
index 80a5e0e..08dd4da 100644
--- a/public/index.html
+++ b/public/index.html
@@ -53,6 +53,19 @@ 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)}
+.livedot{width:10px;height:10px;border-radius:50%;background:var(--bad);box-shadow:0 0 0 0 rgba(248,113,113,.7);animation:pulse 1.8s infinite}
+@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(248,113,113,.6)}70%{box-shadow:0 0 0 9px rgba(248,113,113,0)}100%{box-shadow:0 0 0 0 rgba(248,113,113,0)}}
+.feed{display:flex;flex-direction:column;gap:8px;max-height:340px;overflow:auto}
+.fitem{display:flex;gap:12px;align-items:flex-start;padding:9px 11px;background:#11141a;border:1px solid var(--line);border-radius:9px}
+.fitem .ago{font-size:11px;color:var(--dim);white-space:nowrap;min-width:74px;padding-top:2px}
+.fitem .who{font-weight:600}
+.fitem .loc{color:var(--dim);font-size:12px}
+.fitem .msg{font-size:12.5px;color:var(--ink);margin-top:3px}
+.fitem .brows{font-size:11px;color:var(--dim);margin-top:2px}
+.fitem.miss{border-color:#5a3a1a;background:#1a1408}
+.badge{font-size:10px;padding:1px 6px;border-radius:9px;border:1px solid var(--line);color:var(--dim)}
+.badge.m{background:#3a2a08;border-color:#7a5a1a;color:#fbbf24}
+.fresh{border-color:#1a5a3a!important;background:#0c1a12!important}
 .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}
@@ -78,6 +91,17 @@ a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
   <button id="refresh">↻ Refresh from Zendesk</button>
 </header>
 <div class="wrap">
+  <div class="card c12" id="liveCard" style="margin-bottom:20px">
+    <h3 style="display:flex;align-items:center;gap:10px">
+      <span class="livedot"></span> Live — recent chat activity
+      <select id="liveWindow" style="background:#11141a;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 7px;font-size:12px">
+        <option value="6">last 6h</option><option value="24" selected>last 24h</option><option value="72">last 3d</option>
+      </select>
+      <span class="sp" style="flex:1"></span>
+      <span class="mut" id="liveMeta" style="font-size:11px;font-weight:400"></span>
+    </h3>
+    <div id="liveFeed"></div>
+  </div>
   <div class="filterbar" id="filterbar"></div>
   <div class="kpis" id="kpis"></div>
 
diff --git a/recent.py b/recent.py
new file mode 100644
index 0000000..956e65c
--- /dev/null
+++ b/recent.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Fetch RECENT chats (last N hours) from Zendesk Chat and print JSON to stdout.
+Used by the server's /api/recent for the Live feed. Token stays server-side."""
+import json, os, re, sys, time, urllib.request, urllib.parse
+
+ENV = os.path.expanduser("~/Projects/secrets-manager/.env")
+HOURS = float(os.environ.get("HOURS", "8"))
+BASE = "https://www.zopim.com/api/v2/incremental/chats"
+
+
+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 token")
+
+
+def clean_title(t):
+    if not t:
+        return ""
+    return re.sub(r"\s+", " ", re.split(r"[–\-|]\s*Designer Wallcoverings", t)[0]).strip()
+
+
+def main():
+    tok = token()
+    start = int(time.time() - HOURS * 3600)
+    url = BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": start})
+    rows, pages = [], 0
+    while url and pages < 20:
+        req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok})
+        try:
+            d = json.load(urllib.request.urlopen(req, timeout=30))
+        except Exception as e:
+            print(json.dumps({"error": str(e), "chats": []})); return
+        pages += 1
+        for c in d.get("chats", []):
+            s = c.get("session") or {}
+            hist = c.get("history") or []
+            vmsgs = [h.get("msg", "") for h in hist
+                     if isinstance(h, dict) and str(h.get("sender_type", "")).lower() == "visitor" and h.get("msg")]
+            if not vmsgs and c.get("comment"):
+                vmsgs = [c.get("comment")]
+            rows.append({
+                "id": c.get("id"), "ts": c.get("timestamp"), "type": c.get("type"),
+                "country": s.get("country_name") or "Unknown", "country_code": s.get("country_code") or "??",
+                "city": s.get("city") or "", "region": s.get("region") or "", "platform": s.get("platform") or "",
+                "agents": c.get("agent_names") or [], "missed": bool(c.get("missed")),
+                "rating": c.get("rating") or "", "msg": " · ".join(vmsgs)[:200],
+                "browsed": [clean_title(w.get("title")) for w in (c.get("webpath") or []) if w.get("title")][:1],
+            })
+        end = d.get("end_time"); nxt = d.get("next_page")
+        if not d.get("chats") or not end:
+            break
+        url = nxt if nxt else None
+    # keep only chats that actually STARTED within the window (incremental API also returns recently-updated old chats)
+    import calendar
+    cutoff = time.time() - HOURS * 3600
+    def started(r):
+        try:
+            return calendar.timegm(time.strptime(r["ts"], "%Y-%m-%dT%H:%M:%SZ")) >= cutoff
+        except Exception:
+            return False
+    rows = [r for r in rows if started(r)]
+    rows.sort(key=lambda r: r.get("ts") or "", reverse=True)
+    print(json.dumps({"now": int(time.time()), "hours": HOURS, "count": len(rows), "chats": rows[:60]}))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server.js b/server.js
index acfe367..04b0bae 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,16 @@ 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 === 'GET' && req.url.startsWith('/api/recent')) {
+    const hours = (new URL(req.url, 'http://x').searchParams.get('hours')) || '8';
+    execFile('/usr/bin/python3', [path.join(__dirname, 'recent.py')],
+      { env: { ...process.env, HOURS: String(hours) }, maxBuffer: 8 * 1024 * 1024 },
+      (err, so, se) => {
+        res.writeHead(err ? 500 : 200, { 'content-type': 'application/json' });
+        res.end(err ? JSON.stringify({ error: (se||'').trim() || String(err), chats: [] }) : (so || '{"chats":[]}'));
+      });
+    return;
+  }
   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' } },

← 74e12d8 Full data-grid: 19 columns, click-sort + drag-reorder + show  ·  back to Zendesk Chat Analyzer  ·  Token reads from env var (pm2) for Kamatera deploy fd2c68c →