[object Object]

← back to Ga Allsites

Animated Live view: pulsing count-up counter + live-filling sparkline + smoothly-animating per-site bars; 15s poll, 12s cache

56643dc74c6aeff4355a22ea11974facaeef6541 · 2026-08-04 15:00:53 -0700 · Steve Abrams

Files touched

Diff

commit 56643dc74c6aeff4355a22ea11974facaeef6541
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 4 15:00:53 2026 -0700

    Animated Live view: pulsing count-up counter + live-filling sparkline + smoothly-animating per-site bars; 15s poll, 12s cache
---
 server.py | 64 +++++++++++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 46 insertions(+), 18 deletions(-)

diff --git a/server.py b/server.py
index 51ff0c4..d5e3c35 100644
--- a/server.py
+++ b/server.py
@@ -29,7 +29,7 @@ _refresh_lock = threading.Lock()
 _refreshing = {"on": False}
 _live_cache = {"ts": 0, "data": None}
 _live_lock = threading.Lock()
-_LIVE_TTL = 20
+_LIVE_TTL = 12
 
 
 def run_etl():
@@ -141,6 +141,16 @@ PAGE = r"""<!DOCTYPE html><html><head><meta charset="utf-8">
  .pyr .lbl{font-size:12px;color:#333;min-width:150px;text-align:right}
  .pyr .val{font-size:12px;color:#666;min-width:60px;text-align:left;font-variant-numeric:tabular-nums}
  svg text{font:12px -apple-system,sans-serif}
+ /* live moving graphics */
+ @keyframes pulse{0%{transform:scale(1);opacity:.9}70%{transform:scale(2.6);opacity:0}100%{opacity:0}}
+ .livehero{position:relative;padding:22px 26px;background:linear-gradient(120deg,#180d12,#2a1019);color:#fff;display:flex;align-items:center;gap:22px;flex-wrap:wrap}
+ .livedot{width:13px;height:13px;border-radius:50%;background:#e74c3c;position:relative;flex:0 0 auto}
+ .livedot::after{content:'';position:absolute;inset:0;border-radius:50%;background:#e74c3c;animation:pulse 1.6s ease-out infinite}
+ .bignum{font-size:54px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
+ .livelbl{font-size:13px;color:#f0a0b0;margin-top:5px}
+ .lrow{display:grid;grid-template-columns:180px 1fr;align-items:center;gap:12px;margin:6px 0;transition:transform .5s}
+ .lrow .nm{font-size:12px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#333}
+ .lbar{height:26px;border-radius:6px;background:linear-gradient(90deg,#e74c3c,#ff9a6b);color:#fff;display:flex;align-items:center;padding:0 9px;font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;min-width:26px;transition:width .9s cubic-bezier(.34,1.4,.5,1)}
 </style></head><body>
 <nav class="side" id="side">
  <div class="brand">GA<br>FLEET</div>
@@ -194,16 +204,17 @@ PAGE = r"""<!DOCTYPE html><html><head><meta charset="utf-8">
    <div id="maplegend" style="margin-top:10px;font-size:12px;color:#667"></div></div>
  </div>
 
- <!-- LIVE -->
+ <!-- LIVE (moving graphics) -->
  <div class="view" id="v-live" hidden>
-  <div style="padding:16px 18px;background:#1a0f14;color:#fff;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap">
-   <span style="font-size:13px;color:#f5b7b1;font-weight:600">🔴 LIVE · last 30 min</span>
-   <span id="livetot" style="font-size:28px;font-weight:700;font-variant-numeric:tabular-nums">—</span>
-   <span style="font-size:13px;color:#e8a">active users on the fleet right now</span>
-   <span id="livesites" class="muted" style="margin-left:auto;font-size:12px"></span>
+  <div class="livehero">
+   <span class="livedot"></span>
+   <div><div class="bignum" id="livetot">—</div><div class="livelbl">active users on the fleet · last 30 min</div></div>
+   <span id="livesites" style="margin-left:auto;font-size:12px;color:#c9a">connecting…</span>
+  </div>
+  <div class="panel">
+   <div class="card"><h2>Live pulse — active users over this session</h2><div id="livespark"></div></div>
+   <div class="card"><h2>Active right now — by site</h2><div id="livebars"></div></div>
   </div>
-  <table><thead><tr><th class="l">Site</th><th class="l">Account</th><th>Active users (30 min)</th></tr></thead>
-   <tbody id="ltb"></tbody><tfoot><tr id="ltf"></tr></tfoot></table>
  </div>
 </div>
 
@@ -335,17 +346,34 @@ async function renderMap(){
  document.getElementById('maplegend').innerHTML=`Shaded by 30-day sessions (darkest = ${esc((CDATA.rows[0]||{}).country||'')}, ${Math.round(max).toLocaleString()}). Hover any country for its total. Tiny territories (Hong Kong, Singapore) aren't drawn on this low-res map but are listed in the Country tab.`;
 }
 
-/* ---- LIVE ---- */
-let liveTimer=null;
+/* ---- LIVE (moving graphics) ---- */
+let liveTimer=null,LIVEHIST=[],liveNumCur=0;
+function animNum(el,to){const from=liveNumCur,t0=performance.now();
+ (function step(t){const k=Math.min(1,(t-t0)/600);el.textContent=Math.round(from+(to-from)*k).toLocaleString();if(k<1)requestAnimationFrame(step);})(t0);
+ liveNumCur=to;}
+function sparkline(h){const W=760,H=130,p=8;
+ if(h.length<2)return '<div class="z" style="padding:24px">collecting live signal… this fills in as polls arrive (every ~15s)</div>';
+ const max=Math.max(1,...h.map(d=>d.v)),n=h.length;
+ const X=i=>p+(W-2*p)*i/(n-1),Y=v=>H-p-(H-2*p)*v/max;
+ const pts=h.map((d,i)=>`${X(i).toFixed(1)},${Y(d.v).toFixed(1)}`);
+ const lx=X(n-1),ly=Y(h[n-1].v);
+ return `<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-height:160px">
+  <defs><linearGradient id="lg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e74c3c" stop-opacity=".35"/><stop offset="1" stop-color="#e74c3c" stop-opacity="0"/></linearGradient></defs>
+  <path d="M${X(0)},${H-p} L${pts.join(' L')} L${lx},${H-p} Z" fill="url(#lg)"/>
+  <path d="M${pts.join(' L')}" fill="none" stroke="#e74c3c" stroke-width="2.5"/>
+  <circle cx="${lx}" cy="${ly}" r="4" fill="#e74c3c"><animate attributeName="r" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></circle>
+  <text x="${lx-6}" y="${Math.max(16,ly-12)}" text-anchor="end" fill="#c0392b" font-weight="700">${h[n-1].v}</text></svg>`;}
 async function pollLive(){
  try{const r=await fetch(location.origin+'/api/live');const j=await r.json();
-  if(j.error){document.getElementById('livetot').textContent='ERR';document.getElementById('livesites').textContent=j.error;return;}
-  document.getElementById('livetot').textContent=j.total_active.toLocaleString();
+  if(j.error){document.getElementById('livesites').textContent=j.error;return;}
+  animNum(document.getElementById('livetot'),j.total_active);
   document.getElementById('livesites').textContent=`${j.active_sites} site(s) active · ${j.property_count} polled · ${new Date(j.generated_at).toLocaleTimeString()}`;
-  const a=j.rows.filter(r=>r.active>0);
-  document.getElementById('ltb').innerHTML=(a.length?a:[{property:'— no active users on any site right now —',property_id:'',account:'',active:''}]).map(r=>
-    `<tr><td class="l">${r.property_id?`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/realtime/overview" target="_blank" rel="noopener noreferrer">${esc(r.property)}</a>`:esc(r.property)}</td><td class="l acct">${r.account&&r.account.includes('APPS')?'APPS':esc(r.account||'')}</td><td style="font-weight:700;color:#c0392b">${r.active}</td></tr>`).join('');
-  document.getElementById('ltf').innerHTML=`<td class="l">Σ fleet live</td><td class="l"></td><td>${j.total_active}</td>`;
+  LIVEHIST.push({v:j.total_active});if(LIVEHIST.length>40)LIVEHIST.shift();
+  document.getElementById('livespark').innerHTML=sparkline(LIVEHIST);
+  const a=j.rows.filter(x=>x.active>0).slice(0,15),mx=Math.max(1,...a.map(x=>x.active));
+  document.getElementById('livebars').innerHTML=a.length?a.map(x=>
+    `<div class="lrow"><span class="nm">${esc(x.property)}</span><div class="lbar" style="width:${Math.max(8,Math.round(100*x.active/mx))}%">${x.active}</div></div>`).join('')
+    :'<div class="z" style="padding:16px">no active users right now — this lights up the moment someone lands on a site</div>';
  }catch(e){document.getElementById('livesites').textContent='fetch failed: '+e.message;}
 }
 
@@ -359,7 +387,7 @@ function showTab(tab){
  if(tab==='visuals')renderVisuals();
  if(tab==='countries')renderCountries();
  if(tab==='map')renderMap();
- if(tab==='live'){pollLive();liveTimer=setInterval(pollLive,20000);}
+ if(tab==='live'){pollLive();liveTimer=setInterval(pollLive,15000);}
 }
 document.querySelectorAll('.side button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
 

← a26228a Add world Map tab (zero-dep SVG choropleth by sessions) + we  ·  back to Ga Allsites  ·  Add Charts overview page: 7 KPI cards + top-15 domains bar c 74fe872 →