← back to Rentv Ad Engine
analytics page: add per-client trend sparklines (rising/easing), estimated sponsorship value (transparent model + adjustable rate), and guest analytics (interview/panel/verified split + top companies)
d81a056a58e90cdce51723f6303f5592f33f61ac · 2026-08-07 11:21:31 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit d81a056a58e90cdce51723f6303f5592f33f61ac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 11:21:31 2026 -0700
analytics page: add per-client trend sparklines (rising/easing), estimated sponsorship value (transparent model + adjustable rate), and guest analytics (interview/panel/verified split + top companies)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/charts.html | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 94 insertions(+), 3 deletions(-)
diff --git a/public/charts.html b/public/charts.html
index 41e9103..f6af919 100644
--- a/public/charts.html
+++ b/public/charts.html
@@ -63,6 +63,20 @@
<div class="panel" id="timeline"></div>
</div>
</div>
+
+ <h2>Per-client trend <span class="muted" style="text-transform:none;font-weight:400">— airings per episode across the run</span></h2>
+ <div class="gauges" id="trends" style="grid-template-columns:repeat(auto-fill,minmax(240px,1fr))"></div>
+
+ <h2>Estimated sponsorship value
+ <span class="muted" style="text-transform:none;font-weight:400">— model, not RENTV's rate card ·
+ $<input id="rate" type="number" value="750" min="0" step="50" style="width:74px;background:#0e1116;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 6px"> / episode sponsored</span></h2>
+ <div class="panel" id="revenue"></div>
+
+ <h2>Guest analytics</h2>
+ <div class="two">
+ <div class="panel" id="guestSplit"></div>
+ <div class="panel" id="guestCos"></div>
+ </div>
</main>
<script>
@@ -71,12 +85,14 @@ const esc=s=>(s==null?"":String(s)).replace(/[&<>"]/g,m=>({"&":"&","<":"<
const PAL=["#2dd4bf","#4c8bf5","#d4af37","#e5989b","#a78bfa","#f59e0b","#34d399","#f472b6","#60a5fa","#facc15"];
async function boot(){
- const [adv,air,rv]=await Promise.all([
+ const [adv,air,rv,gst]=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(()=>({}))
+ fetch("api/rentvreview").then(r=>r.json()).catch(()=>({})),
+ fetch("api/guests").then(r=>r.json()).catch(()=>({guests:[]}))
]);
- const advertisers=adv.advertisers||[], airings=air.airings||[];
+ const advertisers=adv.advertisers||[], airings=air.airings||[], guests=gst.guests||[];
+ window.__RV=rv;
// aggregate airings per client (fallback to appearances when no airings)
const byClient={};
@@ -94,6 +110,9 @@ async function boot(){
renderBars(clients.slice(0,14),clients[0]?.airings||1);
renderDonut(clients);
renderTimeline(airings,rv);
+ renderTrends(clients.slice(0,8),airings,rv);
+ renderRevenue(clients,airings);
+ renderGuests(guests);
}
// ── LIQUID FILL GAUGES (animated wave) ──
@@ -196,6 +215,78 @@ function renderTimeline(airings,rv){
<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>`;
}
+// ── PER-CLIENT TREND (sparkline of airings per episode across the run) ──
+function renderTrends(top,airings,rv){
+ const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
+ const eps=[...new Set(Object.values(meta))].sort((a,b)=>a-b); // ordered episode #s
+ const el=$("#trends"); el.innerHTML="";
+ if(!eps.length){el.innerHTML='<div class="muted">Trend appears once episode dates are mapped.</div>';return;}
+ const idx={};eps.forEach((e,i)=>idx[e]=i);
+ top.forEach((c,ci)=>{
+ const series=eps.map(()=>0);
+ airings.filter(a=>a.advertiser===c.name).forEach(a=>{const e=meta[a.episode_id];if(e!=null)series[idx[e]]++;});
+ const W=220,H=54,max=Math.max(...series,1);
+ const x=i=>4+i*(W-8)/Math.max(1,series.length-1), y=v=>H-6-(v/max)*(H-14);
+ const col=PAL[ci%PAL.length];
+ let line="",area=`M${x(0)},${H-6}`;
+ series.forEach((v,i)=>{line+=(i?" L":"M")+x(i)+","+y(v);area+=` L${x(i)},${y(v)}`;});
+ area+=` L${x(series.length-1)},${H-6} Z`;
+ const recent=series.slice(-3).reduce((s,v)=>s+v,0),early=series.slice(0,3).reduce((s,v)=>s+v,0);
+ const trend=recent>early?'▲ rising':recent<early?'▼ easing':'▬ steady';
+ const box=document.createElement("div"); box.className="gauge"; box.style.textAlign="left"; box.style.padding="12px";
+ box.innerHTML=`<div class="nm" style="margin:0" title="${esc(c.name)}">${esc(c.name.length>26?c.name.slice(0,25)+"…":c.name)}</div>
+ <svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="margin:4px 0">
+ <path d="${area}" fill="${col}" opacity=".18"></path>
+ <path d="${line}" fill="none" stroke="${col}" stroke-width="2"></path></svg>
+ <div class="sub" style="text-align:left">${c.airings} airings · <span style="color:${recent>=early?'var(--teal)':'var(--dim)'}">${trend}</span></div>`;
+ el.appendChild(box);
+ });
+}
+
+// ── ESTIMATED SPONSORSHIP VALUE (transparent model; adjustable rate) ──
+let __REVDATA=[];
+function renderRevenue(clients,airings){
+ const epsBy={};airings.forEach(a=>{(epsBy[a.advertiser]=epsBy[a.advertiser]||new Set()).add(a.episode_id);});
+ __REVDATA=clients.map(c=>({name:c.name,eps:(epsBy[c.name]||new Set()).size})).filter(c=>c.eps>0).sort((a,b)=>b.eps-a.eps);
+ const draw=()=>{
+ const rate=Math.max(0,+($("#rate").value||0));
+ const rows=__REVDATA.map(c=>({...c,val:c.eps*rate}));
+ const max=Math.max(...rows.map(r=>r.val),1), total=rows.reduce((s,r)=>s+r.val,0);
+ $("#revenue").innerHTML=`<div style="font-size:20px;font-weight:800;margin-bottom:10px">Est. portfolio value: <span style="color:var(--gold)">$${total.toLocaleString()}</span> <span class="muted" style="font-size:12px;font-weight:400">across ${rows.length} clients (episodes-sponsored × rate)</span></div>`+
+ rows.slice(0,14).map((r,i)=>`<div class="bar-row"><div class="bar-lab" title="${esc(r.name)}">${esc(r.name)}</div>
+ <div class="bar-track"><div class="bar-fill" style="width:${6+94*r.val/max}%;background:linear-gradient(90deg,var(--gold),#b8860b)"></div></div>
+ <div class="bar-val" style="width:80px;flex:0 0 80px">$${r.val.toLocaleString()}</div></div>`).join("");
+ };
+ draw(); $("#rate").oninput=draw;
+}
+
+// ── GUEST ANALYTICS ──
+function renderGuests(guests){
+ const iv=guests.filter(g=>!g.is_panel).length, pn=guests.filter(g=>g.is_panel).length;
+ const ver=guests.filter(g=>g.verified).length;
+ const total=guests.length||1, ivPct=Math.round(iv/total*100);
+ $("#guestSplit").innerHTML=`<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap">
+ <svg width="150" height="150" viewBox="0 0 150 150">
+ <circle cx="75" cy="75" r="60" fill="none" stroke="#2a3038" stroke-width="16"></circle>
+ <circle cx="75" cy="75" r="60" fill="none" stroke="var(--teal)" stroke-width="16"
+ stroke-dasharray="${(ivPct/100*377).toFixed(0)} 377" transform="rotate(-90 75 75)" stroke-linecap="round"></circle>
+ <text x="75" y="70" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700">${total}</text>
+ <text x="75" y="90" text-anchor="middle" fill="#9aa7b4" font-size="11">guests</text></svg>
+ <div class="legend" style="flex-direction:column;align-items:flex-start;font-size:13px">
+ <span><i class="dot" style="background:var(--teal)"></i>Interview guests · <b style="color:var(--ink)">${iv}</b></span>
+ <span><i class="dot" style="background:#2a3038"></i>Panelists · <b style="color:var(--ink)">${pn}</b></span>
+ <span><i class="dot" style="background:var(--gold)"></i>Identity-verified · <b style="color:var(--ink)">${ver}</b></span>
+ </div></div>`;
+ // top companies represented
+ const co={};guests.forEach(g=>{const k=(g.company||"").trim();if(k&&k.length>2)co[k]=(co[k]||0)+1;});
+ const top=Object.entries(co).sort((a,b)=>b[1]-a[1]).slice(0,10);
+ const max=Math.max(...top.map(t=>t[1]),1);
+ $("#guestCos").innerHTML=`<div class="muted" style="margin-bottom:8px">Companies represented by guests (top ${top.length})</div>`+
+ top.map(([k,v],i)=>`<div class="bar-row"><div class="bar-lab" title="${esc(k)}">${esc(k)}</div>
+ <div class="bar-track"><div class="bar-fill" style="width:${8+92*v/max}%;background:linear-gradient(90deg,${PAL[i%PAL.length]},${PAL[(i+2)%PAL.length]})"></div></div>
+ <div class="bar-val">${v}</div></div>`).join("");
+}
+
boot();
</script>
</body>
← b873f8f add Client Analytics page (charts.html): animated liquid-fil
·
back to Rentv Ad Engine
·
analytics: add guest-topics word-frequency cloud + ad airing 39e94f3 →