← 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 →