[object Object]

← back to Rentv Ad Engine

analytics: sponsor-tenure Gantt (first->last episode per client, dots per sponsored ep, teal=active / amber=lapsed) — churn + retention at a glance for ad-sales

74b0fd76d32e98d1b6c0df417bb7ad9194c039e5 · 2026-08-07 14:16:49 -0700 · Steve Abrams

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

Files touched

Diff

commit 74b0fd76d32e98d1b6c0df417bb7ad9194c039e5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 14:16:49 2026 -0700

    analytics: sponsor-tenure Gantt (first->last episode per client, dots per sponsored ep, teal=active / amber=lapsed) — churn + retention at a glance for ad-sales
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/charts.html | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)

diff --git a/public/charts.html b/public/charts.html
index 03f3359..a104136 100644
--- a/public/charts.html
+++ b/public/charts.html
@@ -122,6 +122,9 @@
     </div>
   </div>
 
+  <h2>Sponsor tenure <span class="muted" style="text-transform:none;font-weight:400">— first → last episode each client sponsored · teal = still active, amber = lapsed</span></h2>
+  <div class="panel" id="tenure"></div>
+
   <h2>Guest ↔ sponsor network <span class="muted" style="text-transform:none;font-weight:400">— gold = sponsor, teal = guest; a link = they shared an episode</span></h2>
   <div class="panel" style="overflow:hidden"><canvas id="net" width="1080" height="560" style="width:100%;max-width:1080px;height:auto"></canvas></div>
 </main>
@@ -166,6 +169,7 @@ async function boot(){
   renderPerClient(clients,airings);
   renderGuestMap(guests);
   renderOverlap(airings);
+  renderTenure(airings,rv);
   renderNetwork(airings,guests);
 }
 
@@ -479,6 +483,34 @@ function renderKpis(clients,airings,guests){
   $("#asof").textContent="— RENTV CRE Talk advertiser report · generated "+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric'});
 }
 
+// ── SPONSOR TENURE GANTT (first→last episode per client) ──
+function renderTenure(airings,rv){
+  const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
+  const byC={};airings.forEach(a=>{const e=meta[a.episode_id];if(e!=null)(byC[a.advertiser]=byC[a.advertiser]||new Set()).add(e);});
+  let rows=Object.entries(byC).map(([name,set])=>{const eps=[...set].sort((a,b)=>a-b);
+    return {name,eps,first:eps[0],last:eps[eps.length-1],n:eps.length};}).filter(r=>r.n>=1);
+  if(!rows.length){$("#tenure").innerHTML='<div class="muted">Tenure appears once episode mapping is complete.</div>';return;}
+  const allEps=rows.flatMap(r=>r.eps),minE=Math.min(...allEps),maxE=Math.max(...allEps);
+  rows.sort((a,b)=>a.first-b.first||b.n-a.n);
+  const W=1000,padL=210,padR=30,rowH=26,H=rows.length*rowH+40;
+  const x=e=>padL+((e-minE)/((maxE-minE)||1))*(W-padL-padR);
+  const active=r=>r.last>=maxE-1;
+  let svg=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="max-width:100%">`;
+  // axis ticks
+  for(let e=minE;e<=maxE;e++){const gx=x(e);svg+=`<line x1="${gx}" y1="24" x2="${gx}" y2="${H-16}" stroke="#1c2530"></line>`;
+    if((e-minE)%2===0||e===maxE)svg+=`<text x="${gx}" y="16" fill="#3a4450" font-size="9" text-anchor="middle">#${e}</text>`;}
+  rows.forEach((r,i)=>{
+    const y=30+i*rowH, col=active(r)?"#2dd4bf":"#d4af37";
+    svg+=`<text x="${padL-10}" y="${y+9}" fill="var(--ink)" font-size="12" text-anchor="end">${esc(r.name.length>30?r.name.slice(0,29)+"…":r.name)}</text>`;
+    svg+=`<rect x="${x(r.first)}" y="${y+2}" width="${Math.max(3,x(r.last)-x(r.first))}" height="12" rx="6" fill="${col}" opacity=".28"></rect>`;
+    r.eps.forEach(e=>{svg+=`<circle cx="${x(e)}" cy="${y+8}" r="4" fill="${col}"><title>Ep #${e}</title></circle>`;});
+    svg+=`<text x="${W-4}" y="${y+11}" fill="var(--dim)" font-size="11" text-anchor="end">${r.n} ep${r.n>1?"s":""}${active(r)?"":" · lapsed"}</text>`;
+  });
+  svg+="</svg>";
+  const act=rows.filter(active).length;
+  $("#tenure").innerHTML=`<div class="muted" style="margin-bottom:8px">${rows.length} clients · <span style="color:var(--teal)">${act} active</span> · <span style="color:var(--gold)">${rows.length-act} lapsed</span> (episodes #${minE}–#${maxE})</div>`+svg;
+}
+
 // ── GUEST ↔ SPONSOR NETWORK (zero-dep force-directed canvas) ──
 function renderNetwork(airings,guests){
   const cv=$("#net"); if(!cv)return; const ctx=cv.getContext("2d"), W=cv.width, H=cv.height;

← e0aef11 analytics: guest<->sponsor network graph (zero-dep force-dir  ·  back to Rentv Ad Engine  ·  5x sweep 1: serve branded favicon route (fixes /favicon.ico cabb3d4 →