← 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
M public/app.jsM public/index.htmlA recent.pyM server.js
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 →