[object Object]

← back to Rentv Ad Engine

add Client Analytics page (charts.html): animated liquid-fill share-of-voice gauges + clients-by-airings bars + category donut + airings-over-run timeline; linked from header (Steve-directed)

b873f8f765385ee71811b17c08aa8a4e2f000601 · 2026-08-07 11:18:35 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b873f8f765385ee71811b17c08aa8a4e2f000601
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 11:18:35 2026 -0700

    add Client Analytics page (charts.html): animated liquid-fill share-of-voice gauges + clients-by-airings bars + category donut + airings-over-run timeline; linked from header (Steve-directed)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/charts.html | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html  |   1 +
 2 files changed, 203 insertions(+)

diff --git a/public/charts.html b/public/charts.html
new file mode 100644
index 0000000..41e9103
--- /dev/null
+++ b/public/charts.html
@@ -0,0 +1,202 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV Ad Engine — Client Analytics</title>
+<style>
+  :root{--bg:#0e1116;--panel:#161b22;--line:#2a3038;--ink:#e6edf3;--dim:#9aa7b4;--teal:#2dd4bf;--gold:#d4af37;--accent:#4c8bf5}
+  *{box-sizing:border-box}
+  body{margin:0;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
+  a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
+  header{position:sticky;top:0;z-index:20;background:linear-gradient(180deg,#11161d,#0e1116);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+  .brand{font-weight:800;letter-spacing:.5px}.brand b{color:var(--gold)}
+  .stat{color:var(--dim);font-size:12px}.stat b{color:var(--ink)}
+  main{padding:20px;max-width:1280px;margin:0 auto}
+  h2{font-size:15px;margin:26px 0 12px;color:var(--dim);font-weight:600;letter-spacing:.4px;text-transform:uppercase}
+  .gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px}
+  .gauge{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;text-align:center}
+  .gauge canvas{width:150px;height:150px;max-width:100%}
+  .gauge .nm{font-weight:700;margin-top:6px;font-size:14px}
+  .gauge .sub{color:var(--dim);font-size:12px}
+  .panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
+  .two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+  @media(max-width:820px){.two{grid-template-columns:1fr}}
+  .bar-row{display:flex;align-items:center;gap:10px;margin:7px 0;font-size:13px}
+  .bar-lab{width:200px;flex:0 0 200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}
+  .bar-lab a{color:var(--ink)}
+  .bar-track{flex:1;background:#0e1116;border-radius:6px;overflow:hidden;height:20px;position:relative;border:1px solid var(--line)}
+  .bar-fill{height:100%;background:linear-gradient(90deg,var(--teal),var(--accent));border-radius:6px 0 0 6px;transition:width 1s cubic-bezier(.2,.8,.2,1)}
+  .bar-val{width:52px;flex:0 0 52px;text-align:right;color:var(--dim)}
+  .legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;font-size:12px}
+  .legend span{display:flex;align-items:center;gap:5px;color:var(--dim)}
+  .dot{width:10px;height:10px;border-radius:3px;display:inline-block}
+  svg{display:block;max-width:100%}
+  .muted{color:var(--dim);font-size:12px}
+</style>
+</head>
+<body>
+<header>
+  <div class="brand">RENTV <b>Ad Engine</b> · Client Analytics</div>
+  <div class="stat" id="stat"></div>
+  <div style="margin-left:auto;display:flex;gap:10px;font-size:12px">
+    <a href="./">← Directory</a>
+    <a href="https://www.rentvreview.com/Producers/View/618" target="_blank" rel="noopener">🎬 CRE Talk episodes</a>
+  </div>
+</header>
+
+<main>
+  <h2>Share of voice — top clients (liquid gauges)</h2>
+  <div class="muted" style="margin:-6px 0 10px">Each gauge fills to the client's share of all detected ad airings. Live wave = active sponsor.</div>
+  <div class="gauges" id="gauges"></div>
+
+  <h2>Clients by ad airings</h2>
+  <div class="panel" id="bars"></div>
+
+  <div class="two" style="margin-top:8px">
+    <div>
+      <h2>Category mix</h2>
+      <div class="panel" id="donut"></div>
+    </div>
+    <div>
+      <h2>Ad airings over the show's run</h2>
+      <div class="panel" id="timeline"></div>
+    </div>
+  </div>
+</main>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>(s==null?"":String(s)).replace(/[&<>"]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[m]));
+const PAL=["#2dd4bf","#4c8bf5","#d4af37","#e5989b","#a78bfa","#f59e0b","#34d399","#f472b6","#60a5fa","#facc15"];
+
+async function boot(){
+  const [adv,air,rv]=await Promise.all([
+    fetch("api/advertisers").then(r=>r.json()).catch(()=>({advertisers:[]})),
+    fetch("api/adairings").then(r=>r.json()).catch(()=>({airings:[]})),
+    fetch("api/rentvreview").then(r=>r.json()).catch(()=>({}))
+  ]);
+  const advertisers=adv.advertisers||[], airings=air.airings||[];
+
+  // aggregate airings per client (fallback to appearances when no airings)
+  const byClient={};
+  airings.forEach(a=>byClient[a.advertiser]=(byClient[a.advertiser]||0)+1);
+  let clients=advertisers.map(a=>({name:a.name,category:a.category||"other",
+    airings:byClient[a.name]||0, appearances:a.appearances||0,
+    verified:!!a.review_verified}))
+    .filter(c=>c.airings>0||c.appearances>=2);
+  clients.sort((x,y)=>(y.airings-x.airings)||(y.appearances-x.appearances));
+  const totalAir=clients.reduce((s,c)=>s+c.airings,0)||1;
+
+  $("#stat").innerHTML=`<b>${clients.length}</b> advertising clients · <b>${airings.length}</b> ad airings · <b>${advertisers.length}</b> advertisers total`;
+
+  renderGauges(clients.slice(0,6),totalAir);
+  renderBars(clients.slice(0,14),clients[0]?.airings||1);
+  renderDonut(clients);
+  renderTimeline(airings,rv);
+}
+
+// ── LIQUID FILL GAUGES (animated wave) ──
+function renderGauges(top,total){
+  const wrap=$("#gauges"); wrap.innerHTML="";
+  top.forEach((c,i)=>{
+    const pct=Math.max(0.02,c.airings/total);
+    const col=PAL[i%PAL.length];
+    const box=document.createElement("div"); box.className="gauge";
+    box.innerHTML=`<canvas width="300" height="300"></canvas>
+      <div class="nm" title="${esc(c.name)}">${esc(c.name.length>22?c.name.slice(0,21)+"…":c.name)}</div>
+      <div class="sub">${c.airings} airings · ${Math.round(pct*100)}% share${c.verified?' · <span style="color:var(--gold)">✓</span>':''}</div>`;
+    wrap.appendChild(box);
+    liquid(box.querySelector("canvas"),pct,col,i*400);
+  });
+}
+function liquid(cv,pct,color,phase0){
+  const ctx=cv.getContext("2d"), W=cv.width,H=cv.height,R=W/2-10,cx=W/2,cy=H/2;
+  let t=phase0/1000, fill=0;
+  (function frame(){
+    t+=0.03; fill+=(pct-fill)*0.05;             // ease the fill up on load
+    ctx.clearRect(0,0,W,H);
+    // outer ring
+    ctx.beginPath();ctx.arc(cx,cy,R,0,Math.PI*2);ctx.strokeStyle=color;ctx.globalAlpha=.35;ctx.lineWidth=6;ctx.stroke();ctx.globalAlpha=1;
+    // clip to circle
+    ctx.save();ctx.beginPath();ctx.arc(cx,cy,R-6,0,Math.PI*2);ctx.clip();
+    const level=cy+R-6-(fill*(2*(R-6)));       // y of water line
+    const amp=8+6*Math.min(1,pct*2), len=W;
+    ctx.beginPath();ctx.moveTo(0,H);
+    for(let x=0;x<=len;x++){const y=level+Math.sin((x/len*4*Math.PI)+t)*amp+Math.sin((x/len*2*Math.PI)-t*1.3)*amp*0.5;ctx.lineTo(x,y);}
+    ctx.lineTo(W,H);ctx.closePath();
+    const g=ctx.createLinearGradient(0,level-amp,0,H);g.addColorStop(0,color);g.addColorStop(1,color+"aa");
+    ctx.fillStyle=g;ctx.globalAlpha=.85;ctx.fill();ctx.globalAlpha=1;
+    ctx.restore();
+    // % text
+    ctx.fillStyle="#e6edf3";ctx.font="700 46px -apple-system,Segoe UI,Roboto";ctx.textAlign="center";ctx.textBaseline="middle";
+    ctx.fillText(Math.round(fill*100)+"%",cx,cy);
+    requestAnimationFrame(frame);
+  })();
+}
+
+// ── HORIZONTAL BARS ──
+function renderBars(rows,max){
+  const el=$("#bars"); el.innerHTML="";
+  rows.forEach((c,i)=>{
+    const r=document.createElement("div"); r.className="bar-row";
+    r.innerHTML=`<div class="bar-lab"><a href="./?q=${encodeURIComponent(c.name)}" title="${esc(c.name)}">${esc(c.name)}</a></div>
+      <div class="bar-track"><div class="bar-fill" style="width:0"></div></div>
+      <div class="bar-val">${c.airings}</div>`;
+    el.appendChild(r);
+    requestAnimationFrame(()=>{r.querySelector(".bar-fill").style.width=(6+94*c.airings/max)+"%";
+      r.querySelector(".bar-fill").style.background=`linear-gradient(90deg,${PAL[i%PAL.length]},${PAL[(i+3)%PAL.length]})`;});
+  });
+}
+
+// ── DONUT (category mix) ──
+function renderDonut(clients){
+  const cat={};
+  clients.forEach(c=>{const k=(c.category||"other").split(/[|,/]/)[0].trim();cat[k]=(cat[k]||0)+(c.airings||1);});
+  const entries=Object.entries(cat).sort((a,b)=>b[1]-a[1]);
+  const total=entries.reduce((s,e)=>s+e[1],0)||1;
+  const cx=110,cy=110,r=90,rin=52;let a0=-Math.PI/2,segs="";
+  entries.forEach(([k,v],i)=>{
+    const a1=a0+v/total*Math.PI*2;
+    const x0=cx+r*Math.cos(a0),y0=cy+r*Math.sin(a0),x1=cx+r*Math.cos(a1),y1=cy+r*Math.sin(a1);
+    const xi1=cx+rin*Math.cos(a1),yi1=cy+rin*Math.sin(a1),xi0=cx+rin*Math.cos(a0),yi0=cy+rin*Math.sin(a0);
+    const big=a1-a0>Math.PI?1:0;
+    segs+=`<path d="M${x0},${y0} A${r},${r} 0 ${big} 1 ${x1},${y1} L${xi1},${yi1} A${rin},${rin} 0 ${big} 0 ${xi0},${yi0} Z" fill="${PAL[i%PAL.length]}" opacity=".9"></path>`;
+    a0=a1;
+  });
+  $("#donut").innerHTML=`<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
+    <svg width="220" height="220" viewBox="0 0 220 220">${segs}
+      <text x="110" y="104" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700">${entries.length}</text>
+      <text x="110" y="126" text-anchor="middle" fill="#9aa7b4" font-size="11">categories</text></svg>
+    <div class="legend" style="flex-direction:column;align-items:flex-start">
+      ${entries.map(([k,v],i)=>`<span><i class="dot" style="background:${PAL[i%PAL.length]}"></i>${esc(k)} · ${Math.round(v/total*100)}%</span>`).join("")}
+    </div></div>`;
+}
+
+// ── TIMELINE (airings per episode across the run) ──
+function renderTimeline(airings,rv){
+  // map episode_id -> {ep#, date} from rentvreview; count airings per episode
+  const meta={};Object.entries(rv||{}).forEach(([vid,r])=>meta[vid]={ep:r.ep,date:r.date});
+  const cnt={};airings.forEach(a=>cnt[a.episode_id]=(cnt[a.episode_id]||0)+1);
+  let pts=Object.entries(cnt).map(([vid,n])=>({ep:meta[vid]?.ep??null,date:meta[vid]?.date||"",n})).filter(p=>p.ep!=null);
+  pts.sort((a,b)=>a.ep-b.ep);
+  if(!pts.length){$("#timeline").innerHTML='<div class="muted">Episode-dated airings appear here once The REview mapping is complete.</div>';return;}
+  const W=520,H=200,pad=30,max=Math.max(...pts.map(p=>p.n),1);
+  const x=i=>pad+i*(W-2*pad)/Math.max(1,pts.length-1), y=v=>H-pad-(v/max)*(H-2*pad);
+  let area=`M${x(0)},${H-pad}`, line="";
+  pts.forEach((p,i)=>{area+=` L${x(i)},${y(p.n)}`;line+=(i?" L":"M")+x(i)+","+y(p.n);});
+  area+=` L${x(pts.length-1)},${H-pad} Z`;
+  const dots=pts.map((p,i)=>`<circle cx="${x(i)}" cy="${y(p.n)}" r="3" fill="#2dd4bf"><title>Ep #${p.ep}${p.date?" · "+p.date:""} — ${p.n} airings</title></circle>`).join("");
+  $("#timeline").innerHTML=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
+    <defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2dd4bf" stop-opacity=".45"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></linearGradient></defs>
+    <path d="${area}" fill="url(#ag)"></path>
+    <path d="${line}" fill="none" stroke="#2dd4bf" stroke-width="2"></path>
+    ${dots}
+    <text x="${pad}" y="${H-8}" fill="#9aa7b4" font-size="10">Ep #${pts[0].ep}</text>
+    <text x="${W-pad}" y="${H-8}" fill="#9aa7b4" font-size="10" text-anchor="end">Ep #${pts[pts.length-1].ep}</text>
+  </svg><div class="muted">Detected ad airings per episode (from transcript timing), ordered by episode number.</div>`;
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ddcbc7c..7e0eca0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -72,6 +72,7 @@
     <a href="https://soundcloud.com/rentvnews" target="_blank" rel="noopener" title="RENTV podcast on SoundCloud">🎧 Podcast</a>
     <a href="https://www.rentvreview.com" target="_blank" rel="noopener" title="The REview — RENTV's authoritative episode archive">📺 The REview</a>
     <a href="https://www.rentvreview.com/Producers/View/618" target="_blank" rel="noopener" title="RENTV CRE Talk — all episodes + official sponsors on The REview">🎬 CRE Talk episodes</a>
+    <a href="charts.html" title="Client analytics — graphs + liquid charts">📊 Analytics</a>
   </div>
 </header>
 

← 2ed2dbe auto-data-snapshot: 2026-08-07T11:14:36 (8 data files) — dat  ·  back to Rentv Ad Engine  ·  analytics page: add per-client trend sparklines (rising/easi d81a056 →