[object Object]

← back to Butlr

live page: completed-calls history + reconcile prod drift

56de574e00b208ca74ec5f3f703d4e09b9aa0518 · 2026-05-26 09:51:17 -0700 · SteveStudio2

Adds a 'Completed calls' history section below the live grid on
live.agentabrams.com — fetches /api/done?limit=24 every 30s and renders
each done call with business name, status pill, time-ago, goal snippet,
and an inline <audio> player streaming from /api/calls/:id/recording
(same-origin session-cookie auth, token stays out of the DOM). onerror
gracefully swaps a failed player for a 'no recording archived' note.

Also reconciles 31 lines of prod drift: the Kamatera copy had auto-play-
on-connect + the shared-AudioContext unlock banner that were never
committed back to this repo. This commit pulls prod's full current state
(342 lines) plus the new history section (→408) so Mac2 = prod again.

Files touched

Diff

commit 56de574e00b208ca74ec5f3f703d4e09b9aa0518
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 09:51:17 2026 -0700

    live page: completed-calls history + reconcile prod drift
    
    Adds a 'Completed calls' history section below the live grid on
    live.agentabrams.com — fetches /api/done?limit=24 every 30s and renders
    each done call with business name, status pill, time-ago, goal snippet,
    and an inline <audio> player streaming from /api/calls/:id/recording
    (same-origin session-cookie auth, token stays out of the DOM). onerror
    gracefully swaps a failed player for a 'no recording archived' note.
    
    Also reconciles 31 lines of prod drift: the Kamatera copy had auto-play-
    on-connect + the shared-AudioContext unlock banner that were never
    committed back to this repo. This commit pulls prod's full current state
    (342 lines) plus the new history section (→408) so Mac2 = prod again.
---
 routes/live.js | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 99 insertions(+), 2 deletions(-)

diff --git a/routes/live.js b/routes/live.js
index 80f9b9d..38a0de5 100644
--- a/routes/live.js
+++ b/routes/live.js
@@ -112,19 +112,56 @@ function renderLive(calls) {
     <span class="count"><span id="liveCount">${live.length}</span> active</span>
     <span class="sub" style="margin-left:auto">auto-refresh 3s · click 🔊 Listen to hear live (no page reload)</span>
   </header>
+  <div id="audio-unlock-banner" onclick="unlockAudio()" style="background:#22c55e;color:#022;text-align:center;padding:10px;font-weight:700;cursor:pointer;font-size:14px;">🔊 CLICK HERE ONCE TO ENABLE LIVE AUDIO — then calls play automatically as they connect</div>
+  <div style="display:none">
+  </div>
   <main id="grid">${cardsHtml}</main>
 
+  <section id="completed-section" style="max-width:1100px;margin:0 auto;padding:8px 32px 80px;">
+    <h2 style="font-family:'Playfair Display',Georgia,serif;font-weight:500;font-size:20px;color:#f8fafc;border-top:1px solid var(--line);padding-top:24px;margin:24px 0 4px;">Completed calls <span id="doneCount" style="font-size:13px;color:var(--muted);font-weight:400;"></span></h2>
+    <p class="sub" style="margin:0 0 16px;">recent successful calls · play the recording inline</p>
+    <div id="doneGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;"></div>
+  </section>
+
 <script>
 (function(){
   // Map of callId → { ws, audioCtx, btn, stateEl }
   const sessions = new Map();
 
+  // Shared AudioContext — browsers require a user gesture to start audio.
+  // We unlock ONE context on the first click anywhere, then reuse it for every
+  // call so auto-play-on-connect actually produces sound.
+  let sharedCtx = null;
+  let audioUnlocked = false;
+  function ensureCtx(sampleRate) {
+    if (!sharedCtx) {
+      sharedCtx = new (window.AudioContext || window.webkitAudioContext)();
+    }
+    if (sharedCtx.state === 'suspended') sharedCtx.resume();
+    return sharedCtx;
+  }
+  function unlockAudio() {
+    if (audioUnlocked) return;
+    try {
+      ensureCtx();
+      // play a 1-sample silent buffer to fully unlock on iOS/Safari
+      const b = sharedCtx.createBuffer(1, 1, 22050);
+      const s = sharedCtx.createBufferSource();
+      s.buffer = b; s.connect(sharedCtx.destination); s.start(0);
+      audioUnlocked = true;
+      const banner = document.getElementById('audio-unlock-banner');
+      if (banner) { banner.style.display = 'none'; }
+    } catch (e) {}
+  }
+  document.addEventListener('click', unlockAudio, { once: false });
+  document.addEventListener('keydown', unlockAudio, { once: false });
+
   function getScheme() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; }
 
   function startListen(callId, sampleRate, btn, stateEl) {
     let audioCtx, ws, nextPlayTime, frameCount = 0;
     try {
-      audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
+      audioCtx = ensureCtx(sampleRate);
       nextPlayTime = audioCtx.currentTime + 0.1;
     } catch (e) {
       stateEl.textContent = 'no audio support';
@@ -179,7 +216,7 @@ function renderLive(calls) {
     const s = sessions.get(callId);
     if (!s) return;
     try { s.ws && s.ws.close(); } catch(e){}
-    try { s.audioCtx && s.audioCtx.close(); } catch(e){}
+    /* keep shared ctx alive for reuse */
     sessions.delete(callId);
     s.btn.textContent = '🔊 Listen';
     s.btn.classList.remove('listening');
@@ -258,6 +295,66 @@ function renderLive(calls) {
     } catch (e) { /* swallow */ }
   }
   setInterval(refresh, 3000);
+
+  // ── Completed-calls history ──────────────────────────────────────────
+  // Fetches the last N status=done calls and renders them with an inline
+  // <audio> player streaming from /api/calls/:id/recording (same-origin,
+  // session-cookie auth — token stays out of the DOM). Refreshes every 30s
+  // so a call that just finished slides into the history without a reload.
+  function doneEsc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
+      return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
+    });
+  }
+  function doneAgo(ts) {
+    try {
+      var ms = Date.now() - new Date(ts).getTime();
+      if (ms < 60000) return Math.floor(ms/1000) + 's ago';
+      if (ms < 3600000) return Math.floor(ms/60000) + 'm ago';
+      if (ms < 86400000) return Math.floor(ms/3600000) + 'h ago';
+      return Math.floor(ms/86400000) + 'd ago';
+    } catch(e) { return ''; }
+  }
+  async function loadCompleted() {
+    try {
+      var r = await fetch('/api/done?limit=24', { credentials: 'same-origin' });
+      var j = await r.json();
+      var calls = (j.calls || []);
+      var countEl = document.getElementById('doneCount');
+      var grid = document.getElementById('doneGrid');
+      if (!grid) return;
+      if (countEl) countEl.textContent = calls.length ? '· ' + calls.length + ' shown' : '';
+      if (!calls.length) {
+        grid.innerHTML = '<div style="grid-column:1/-1;color:var(--muted);font-size:13px;padding:24px 0;">No completed calls yet.</div>';
+        return;
+      }
+      grid.innerHTML = calls.map(function(c){
+        var biz = doneEsc(c.business_name || 'Unknown');
+        var phone = doneEsc(c.business_phone || '');
+        var goal = doneEsc((c.goal || '').slice(0, 140));
+        var ago = doneAgo(c.updated_at || c.created_at);
+        var id = doneEsc(c.id);
+        return '<div class="card status-done" style="border-left:4px solid var(--muted);">' +
+          '<div class="biz">' + biz + '</div>' +
+          '<div class="meta">' +
+            '<span class="pill" style="background:#475569;color:#e2e8f0;">DONE</span>' +
+            (phone ? '<span>' + phone + '</span>' : '') +
+            '<span style="margin-left:auto;">' + ago + '</span>' +
+          '</div>' +
+          (goal ? '<div class="goal">' + goal + (c.goal && c.goal.length > 140 ? '\\u2026' : '') + '</div>' : '') +
+          '<audio controls preload="none" src="/api/calls/' + id + '/recording" ' +
+            'style="width:100%;height:34px;margin-top:6px;" ' +
+            'onerror="this.style.display=\\'none\\';this.insertAdjacentHTML(\\'afterend\\',\\'<span style=&quot;color:#64748b;font-size:11px&quot;>no recording archived</span>\\')"></audio>' +
+          '<div style="margin-top:6px;">' +
+            '<a href="https://butlr.agentabrams.com/calls/' + id + '" target="_blank" rel="noopener" ' +
+            'style="color:var(--muted);font-size:11px;text-decoration:none;">call detail \\u2192</a>' +
+          '</div>' +
+          '</div>';
+      }).join('');
+    } catch (e) { /* swallow */ }
+  }
+  loadCompleted();
+  setInterval(loadCompleted, 30000);
 })();
 </script>
 </body>

← 1295f61 sync local from prod — reconcile butlr drift  ·  back to Butlr  ·  live page: chime on every new call (two-tone Web Audio beep, 4ef812b →