Skip to content

Add temporary patch fixer #2

Add temporary patch fixer

Add temporary patch fixer #2

name: Temporary Release Radar patch
on:
push:
paths:
- '.github/workflows/release-radar-patch.yml'
permissions:
contents: write
jobs:
patch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Patch index.html
shell: bash
run: |
python - <<'PY'
from pathlib import Path
import re
path = Path('index.html')
s = path.read_text(encoding='utf-8')
def once(old, new, label):
global s
n = s.count(old)
if n != 1:
raise RuntimeError(f'{label}: expected exactly 1 match, found {n}')
s = s.replace(old, new, 1)
def between(start, end, replacement, label):
global s
i = s.find(start)
if i < 0:
raise RuntimeError(f'{label}: start marker not found')
j = s.find(end, i + len(start))
if j < 0:
raise RuntimeError(f'{label}: end marker not found')
s = s[:i] + replacement + s[j:]
# Small UI affordances for sortable headers.
once(
'thead th{background:var(--panel-2);color:var(--muted);text-transform:uppercase;font-size:10.5px;letter-spacing:.04em;font-weight:800;text-align:left;padding:11px 14px;border-bottom:1px solid var(--border);position:sticky;top:0}\n',
'thead th{background:var(--panel-2);color:var(--muted);text-transform:uppercase;font-size:10.5px;letter-spacing:.04em;font-weight:800;text-align:left;padding:11px 14px;border-bottom:1px solid var(--border);position:sticky;top:0}\n'
'thead th.sortable{cursor:pointer;user-select:none;transition:color .15s}\n'
'thead th.sortable:hover{color:var(--text)}\n'
'.sort-ind{display:inline-block;min-width:10px;margin-left:4px;color:var(--accent);font-size:9px}\n',
'sortable table CSS')
# Download chart: activity over real snapshot time, or honest per-release bars.
between(
' <!-- main chart -->',
' <!-- platform + adoption -->',
''' <!-- main chart -->
<div class="section">

Check failure on line 61 in .github/workflows/release-radar-patch.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/release-radar-patch.yml

Invalid workflow file

You have an error in your yaml syntax on line 61
<div class="section-head">
<h3>Download activity</h3>
<div class="controls">
<div class="ctl"><label>View</label><select id="cGroup" onchange="onMainGroupChange()">
<option value="activity">Over time</option>
<option value="version">By release</option>
</select></div>
<div class="ctl"><label>Metric</label><select id="cMetric" onchange="drawMain()"></select></div>
</div>
</div>
<div class="card chart-box"><div class="chart-h"><canvas id="mainChart"></canvas></div>
<div id="mainNote" style="font-size:11.5px;color:var(--faint);margin-top:12px"></div></div>
</div>
''',
'main chart HTML')
# Update channel gets a real time-based direct-vs-update view as the default.
between(
' <!-- update channel (only for repos that publish an auto-update feed) -->',
' <!-- table -->',
''' <!-- update channel (only for repos that publish an auto-update feed) -->
<div class="section hidden" id="updSection">
<div class="section-head">
<h3>Update channel</h3>
<div class="controls">
<div class="ctl"><label>View</label><select id="uView" onchange="drawUpd()">
<option value="activity">Direct vs in-app over time</option>
<option value="version">Direct vs in-app by version</option>
<option value="platform">Direct vs in-app by platform</option>
<option value="checks">Update checks over time</option>
</select></div>
</div>
</div>
<div id="updKpis" class="kpis" style="margin-bottom:16px"></div>
<div class="card chart-box"><div class="chart-h"><canvas id="updChart"></canvas></div>
<div id="updNote" style="font-size:11.5px;color:var(--faint);margin-top:12px"></div></div>
</div>
''',
'update channel HTML')
# Repo comparison becomes a cumulative timeline instead of a static bar ranking.
between(
' <!-- comparison -->',
' <!-- snapshots -->',
''' <!-- comparison -->
<div class="section">
<div class="section-head"><h3>Compare repositories</h3>
<div class="controls" style="margin-left:auto">
<div class="ctl"><label>View</label><select id="cmpMode" onchange="renderCompare()">
<option value="absolute">Absolute downloads</option>
<option value="growth">Growth since tracking</option>
</select></div>
<button class="iconbtn" onclick="addCurrentToCompare()">+ Add current repo</button>
</div>
</div>
<div class="cmp-add">
<input id="cmpInput" placeholder="owner/repo to compare" onkeydown="if(event.key==='Enter')addCompare()">
<button class="btn btn-ghost" onclick="addCompare()">Add</button>
</div>
<div class="card chart-box hidden" id="cmpChartCard" style="margin-bottom:12px"><div class="chart-h"><canvas id="cmpChart"></canvas></div>
<div id="cmpNote" style="font-size:11.5px;color:var(--faint);margin-top:12px"></div>
</div>
<div id="cmpList" class="cmp-list"></div>
</div>
''',
'compare HTML')
# State/storage for table ordering and comparison history.
once(
"const S = { repo:null, meta:null, releases:null, versions:[], summary:null, months:{}, mainChart:null, platChart:null, adoptChart:null, updChart:null, starChart:null, starSeries:null, showMeta:false, mode:'inst', rules:{} };",
"const S = { repo:null, meta:null, releases:null, versions:[], summary:null, months:{}, mainChart:null, platChart:null, adoptChart:null, updChart:null, cmpChart:null, starChart:null, starSeries:null, showMeta:false, mode:'inst', rules:{}, tableSort:{key:'published',dir:'desc'} };",
'state')
once(
"const LS = { snaps:'rr_snaps_v1', recent:'rr_recent_v1', theme:'rr_theme_v1', cmp:'rr_cmp_v1', showMeta:'rr_showmeta_v1', token:'rr_token_v1', mode:'rr_mode_v1', platRules:'rr_platrules_v1' };",
"const LS = { snaps:'rr_snaps_v1', recent:'rr_recent_v1', theme:'rr_theme_v1', cmp:'rr_cmp_v1', cmpHist:'rr_cmp_hist_v1', showMeta:'rr_showmeta_v1', token:'rr_token_v1', mode:'rr_mode_v1', platRules:'rr_platrules_v1' };",
'storage keys')
once(
"const pct = (n,d) => d ? Math.round(100*n/d) : 0;",
"const pct = (n,d) => d ? Math.round(100*n/d) : 0;\nconst fmtRate = n => n==null ? '—' : (Math.abs(n)<10 ? (+n).toFixed(1) : Math.round(n).toLocaleString('en-US'));",
'rate formatter')
once(
" if(S.starChart)drawStarChart(); }",
" if(S.cmpChart)drawCompareChart();\n if(S.starChart)drawStarChart(); }",
'theme compare redraw')
# Stable releases are only superseded by newer stable releases. Prereleases remain
# their own fast-moving channel. Per-day values are lifetime averages, not divided by
# the short period a version happened to be the newest tag.
between(
'function buildModel(releases){',
'/* ---------- snapshots (the killer feature) ---------- */',
'''function buildModel(releases){
S.releases=releases;
resetClassifyCache();
const ctx={hasWindows:false};
releases.forEach(r=>(r.assets||[]).forEach(a=>{
const c=classifyAsset(a.name,null);
if(c.kind==='installer' && c.cat==='Windows') ctx.hasWindows=true;
}));
resetClassifyCache();
S.ctx=ctx;
let vs=releases.map(r=>{
const cats={Windows:0,Mac:0,Linux:0,Android:0,Other:0};
const kinds=ZERO_KINDS(), byPlat=ZERO_SPLIT();
const assets=[], byName={};
(r.assets||[]).forEach(a=>{
const c=classifyAsset(a.name,ctx);
const rec={ name:a.name, count:a.download_count, cat:c.cat, kind:c.kind, meta:c.meta,
arch:c.arch, base:c.base, ambiguous:c.ambiguous, overridden:!!c.overridden,
pairedWith:null, payloadFor:null, inapp:0, direct:0 };
assets.push(rec); byName[lc(a.name)]=rec;
kinds[c.kind]+=rec.count;
if(c.kind==='installer') cats[c.cat]+=rec.count;
});
let clamped=0;
assets.forEach(p=>{
if(p.kind!=='payload'||!p.base) return;
const inst=byName[lc(p.base)];
if(!inst||inst.kind!=='installer') return;
p.payloadFor=inst.name; inst.pairedWith=p.name;
inst.inapp=Math.min(p.count,inst.count);
if(p.count>inst.count) clamped++;
});
let inapp=0;
assets.forEach(a=>{ if(a.kind!=='installer') return;
a.direct=a.count-a.inapp; inapp+=a.inapp;
byPlat[a.cat].inapp+=a.inapp; byPlat[a.cat].direct+=a.direct; });
const metaTotal=kinds.payload+kinds.feed+kinds.verify+kinds.other;
return { version:r.tag_name, name:r.name, date:new Date(r.published_at), prerelease:r.prerelease,
cats, kinds, byPlat, metaTotal, assets, inapp, direct:kinds.installer-inapp, clamped };
}).filter(v=>v.date instanceof Date && !isNaN(v.date)).sort((a,b)=>b.date-a.date);
const today=new Date(); const months={};
const sum={Windows:0,Mac:0,Linux:0,Android:0,Other:0,installTotal:0,metaTotal:0,total:0,
kinds:ZERO_KINDS(),byPlat:ZERO_SPLIT(),inapp:0,direct:0,clamped:0,ambiguous:[]};
vs.forEach(v=>{
const superseder=vs.filter(x=>x.date>v.date && (v.prerelease || !x.prerelease))
.sort((a,b)=>a.date-b.date)[0]||null;
const liveEnd=superseder?superseder.date:today;
v.isCurrentChannel=!superseder;
v.days_live=Math.max(0,Math.floor((liveEnd-v.date)/864e5));
v.age=Math.max(0,Math.floor((today-v.date)/864e5));
v.windows=v.cats.Windows; v.mac=v.cats.Mac; v.linux=v.cats.Linux; v.android=v.cats.Android; v.other=v.cats.Other;
v.meta=v.metaTotal;
v.installTotal=v.installerTotal=v.kinds.installer;
v.checks=v.kinds.feed;
v.total_downloads=v.installTotal+v.metaTotal;
const lifetimeDays=Math.max(1,(today-v.date)/864e5);
v.dpdAll=+(v.total_downloads/lifetimeDays).toFixed(1);
v.dpdInst=+(v.installerTotal/lifetimeDays).toFixed(1);
v.downloads_per_day=v.dpdAll;
v.monthKey=v.date.toISOString().slice(0,7);
(months[v.monthKey]=months[v.monthKey]||[]).push(v);
['Windows','Mac','Linux','Android','Other'].forEach(k=>{ sum[k]+=v.cats[k];
sum.byPlat[k].direct+=v.byPlat[k].direct; sum.byPlat[k].inapp+=v.byPlat[k].inapp; });
Object.keys(sum.kinds).forEach(k=>sum.kinds[k]+=v.kinds[k]);
sum.installTotal+=v.installTotal; sum.metaTotal+=v.metaTotal; sum.total+=v.total_downloads;
sum.inapp+=v.inapp; sum.direct+=v.direct; sum.clamped+=v.clamped;
v.assets.forEach(a=>{ if(a.ambiguous) sum.ambiguous.push({name:a.name,count:a.count}); });
});
sum.installerTotal=sum.installTotal;
sum.checks=sum.kinds.feed;
sum.hasUpdateChannel = sum.kinds.payload>0 || sum.kinds.feed>0;
vs.forEach(v=>{
v.shareAll = sum.total ? +(100*v.total_downloads/sum.total).toFixed(1) : 0;
v.shareInst = sum.installerTotal ? +(100*v.installerTotal/sum.installerTotal).toFixed(1) : 0;
v.share = v.shareAll;
});
S.versions=vs; S.summary=sum; S.months=months;
}
''',
'buildModel')
# Real rate series from snapshot deltas. Direct/in-app activity is derived per interval
# from installer and paired payload deltas, which is more useful than subtracting two
# lifetime cumulative guesses.
activity_helpers = r'''function intervalUpdateSplit(from,to){
const fromPa=from.pa||{}, toPa=to.pa||{};
if(!Object.keys(toPa).length){
const a=snapVal(from,'inapp'), b=snapVal(to,'inapp');
const c=snapVal(from,'direct'), d=snapVal(to,'direct');
return {inapp:(a==null||b==null)?null:Math.max(0,b-a),
direct:(c==null||d==null)?null:Math.max(0,d-c)};
}
const rows=[], payloadByBase=new Map();
for(const key in toPa){
const cut=key.indexOf('::'); if(cut<0) continue;
const tag=key.slice(0,cut), name=key.slice(cut+2);
const c=classifyAsset(name,S.ctx||{hasWindows:true});
const delta=Math.max(0,(+toPa[key]||0)-(+fromPa[key]||0));
const row={tag,name,c,delta}; rows.push(row);
if(c.kind==='payload'&&c.base){
const k=tag+'::'+lc(c.base);
payloadByBase.set(k,(payloadByBase.get(k)||0)+delta);
}
}
let inapp=0,direct=0;
rows.forEach(r=>{
if(r.c.kind!=='installer') return;
const pd=payloadByBase.get(r.tag+'::'+lc(r.name))||0;
const upd=Math.min(r.delta,pd);
inapp+=upd; direct+=r.delta-upd;
});
return {inapp,direct};
}
function activityEndpoints(){
const raw=[...repoSnaps()].sort((a,b)=>new Date(a.t)-new Date(b.t));
if(raw.length<2) return {snaps:raw,granularity:null};
const days=new Set(raw.map(s=>new Date(s.t).toISOString().slice(0,10)));
if(days.size>=3){
const byDay=new Map();
raw.forEach(s=>byDay.set(new Date(s.t).toISOString().slice(0,10),s));
return {snaps:[...byDay.values()].sort((a,b)=>new Date(a.t)-new Date(b.t)),granularity:'day'};
}
return {snaps:raw,granularity:'snapshot'};
}
function activityMetricDelta(from,to,metric){
if(metric==='direct'||metric==='inapp') return intervalUpdateSplit(from,to)[metric];
const key=metric==='mode' ? (MODE_INST()?'inst':'all') : SNAP_METRIC[metric];
const a=key?snapVal(from,key):snapTotal(from), b=key?snapVal(to,key):snapTotal(to);
return (a==null||b==null)?null:Math.max(0,b-a);
}
function activitySeries(metric){
const {snaps,granularity}=activityEndpoints(), points=[];
for(let i=1;i<snaps.length;i++){
const from=snaps[i-1],to=snaps[i];
const days=(new Date(to.t)-new Date(from.t))/864e5;
if(days<=0) continue;
const delta=activityMetricDelta(from,to,metric);
if(delta==null) continue;
points.push({x:new Date(to.t).getTime(),
label:new Date(to.t).toLocaleDateString('en-US',{month:'short',day:'numeric',...(granularity==='snapshot'?{hour:'2-digit'}:{})}),
value:delta/days});
}
return {points,granularity};
}
function updateActivitySeries(){
const {snaps,granularity}=activityEndpoints(), points=[];
for(let i=1;i<snaps.length;i++){
const from=snaps[i-1],to=snaps[i];
const days=(new Date(to.t)-new Date(from.t))/864e5;
if(days<=0) continue;
const split=intervalUpdateSplit(from,to);
if(split.direct==null||split.inapp==null) continue;
points.push({x:new Date(to.t).getTime(),
label:new Date(to.t).toLocaleDateString('en-US',{month:'short',day:'numeric',...(granularity==='snapshot'?{hour:'2-digit'}:{})}),
direct:split.direct/days,inapp:split.inapp/days});
}
return {points,granularity};
}
'''
once(
'// Per-version / per-asset gains now come from snapRange(), which diffs the snapshot',
activity_helpers + '// Per-version / per-asset gains now come from snapRange(), which diffs the snapshot',
'activity helpers')
once(
"function applyFilter(){ renderTable(); if($('cGroup').value!=='snapshot') drawMain(); renderFilterInfo(); }",
"function applyFilter(){ renderTable(); if($('cGroup').value==='version') drawMain(); renderFilterInfo(); }",
'filter redraw')
# Render defaults to actual time activity once there are two snapshots.
between(
'function render(){',
'/* Both value sets already exist on the model, so switching modes is a pure re-render. */',
'''function render(){
$('skeleton').classList.add('hidden'); $('results').classList.remove('hidden');
$('fltSearch').value=''; $('fltFrom').value=''; $('fltTo').value='';
renderModeUI();
$('cGroup').value = repoSnaps().length>=2 ? 'activity' : 'version';
populateMetricSelect();
renderSnapBanner(); renderHead(); renderKPIs(); renderInsights();
populateSnapSelects(); renderSnapCompare();
drawMain(); drawPlat(); drawAdopt(); drawUpd(); renderTable(); renderSnapInfo(); renderSnapList(); renderCompare(); renderFilterInfo();
}
''',
'render')
once(
' drawMain(); drawPlat(); drawAdopt(); drawUpd(); renderTable(); renderSnapCompare();',
' drawMain(); drawPlat(); drawAdopt(); drawUpd(); renderTable(); renderSnapCompare(); renderCompare();',
'mode rerender compare')
# Use the effective stable release for the headline KPI and release momentum.
between(
'function renderKPIs(){',
'function renderInsights(){',
'''function latestStableVersion(){ return S.versions.find(v=>!v.prerelease)||S.versions[0]||null; }
function renderKPIs(){
const s=S.summary, v=measuredVelocity(), latest=latestStableVersion(), inst=MODE_INST();
const velVal = v ? Math.round(v.perDay) : (latest?Math.round(mvPerDay(latest)):0);
const velBadge = v ? '<span class="badge measured">measured</span>' : '<span class="badge est">estimated</span>';
const delta=lastDelta();
const noInstHistory = !delta && inst && repoSnaps().length>=2;
const deltaSub = delta
? `<div class="k-sub ${delta.gained>=0?'up':'down'}">${delta.gained>=0?'▲':'▼'} ${fmt(Math.abs(delta.gained))} since last visit</div>`
: `<div class="k-sub">${noInstHistory?'installer history starts with this snapshot':'tracking started'}</div>`;
const topPlat=['Windows','Mac','Linux','Android','Other'].map(k=>[k,s[k]]).sort((a,b)=>b[1]-a[1])[0];
const platPct=pct(topPlat[1],s.installerTotal);
let sub2='';
if(inst && s.metaTotal>0) sub2=`<div class="k-sub">excludes ${fmt(s.metaTotal)} auto-update &amp; checksum files</div>`;
else if(!inst && s.metaTotal>0) sub2=`<div class="k-sub">${fmt(s.installerTotal)} installers · ${fmt(s.metaTotal)} auto-update &amp; checksum files</div>`;
if(inst && s.hasUpdateChannel && s.installerTotal>0)
sub2=`<div class="k-sub">${fmt(s.inapp)} update-associated · ${fmt(s.direct)} direct estimate</div>`+sub2;
const latestLabel=latest&&!latest.prerelease?'Latest stable':'Latest release';
$('kpis').innerHTML=`
<div class="kpi"><div class="k-bar"></div><div class="k-label">Total downloads ${inst?'(installers)':'(all files)'}</div><div class="k-val">${fmt(sTotal())}</div>${deltaSub}${sub2}</div>
<div class="kpi"><div class="k-bar" style="background:var(--good)"></div><div class="k-label">Velocity ${velBadge}</div><div class="k-val">${fmt(velVal)}<span style="font-size:14px;color:var(--faint)">/day</span></div><div class="k-sub">${v?'from your snapshots':'lifetime average for latest stable'}</div></div>
<div class="kpi"><div class="k-bar" style="background:var(--accent-2)"></div><div class="k-label">${latestLabel}</div><div class="k-val" style="font-size:20px">${latest?escapeHtml(latest.version):'—'}</div><div class="k-sub">${latest?fmt(mvTotal(latest))+(inst?' installers · ':' downloads · ')+latest.age+'d old':''}</div></div>
<div class="kpi"><div class="k-bar" style="background:var(${PLAT[topPlat[0]]})"></div><div class="k-label">Top platform</div><div class="k-val" style="font-size:22px">${PLAT_LABEL[topPlat[0]]}</div><div class="k-sub">${platPct}% of installer downloads</div></div>`;
}
''',
'KPIs')
between(
'function renderInsights(){',
'function downloadedPlatforms(){',
'''function renderInsights(){
const s=S.summary, stable=S.versions.filter(v=>!v.prerelease), vs=stable.length?stable:S.versions, out=[];
const topV=[...vs].sort((a,b)=>mvTotal(b)-mvTotal(a))[0];
if(topV) out.push(['--accent',`<b>${escapeHtml(topV.version)}</b> is the most downloaded stable release with <b>${fmt(mvTotal(topV))}</b> downloads (${mvShare(topV)}% of all time).`]);
const topPlat=['Windows','Mac','Linux','Android','Other'].map(k=>[k,s[k]]).sort((a,b)=>b[1]-a[1])[0];
if(topPlat[1]>0 && s.installerTotal>0) out.push([PLAT[topPlat[0]],`<b>${PLAT_LABEL[topPlat[0]]}</b> users dominate — <b>${pct(topPlat[1],s.installerTotal)}%</b> of installer downloads. ${downloadedPlatforms()} platforms detected.`]);
if(s.hasUpdateChannel){
out.push(['--accent-2',`Update channel: <b>${fmt(s.kinds.feed)}</b> update checks and <b>${fmt(s.inapp)}</b> update-associated installer downloads — <b>${pct(s.metaTotal,s.total)}%</b> of all file downloads are update/metadata traffic.`]);
if(s.installerTotal>0) out.push(['--good',`Paired installer/blockmap counts currently imply <b>${pct(s.inapp,s.installerTotal)}%</b> update-associated and <b>${fmt(s.direct)}</b> direct downloads. The time charts use interval deltas for a cleaner estimate.`]);
} else if(s.metaTotal>0){
out.push(['--other',`Auto-update &amp; checksum files: <b>${fmt(s.metaTotal)}</b> downloads (<b>${pct(s.metaTotal,s.total)}%</b> of all files), excluded from platform stats.`]);
}
if(s.ambiguous.length){
const names=s.ambiguous.slice(0,3).map(a=>escapeHtml(a.name)).join(', ');
const more=s.ambiguous.length>3?` and ${s.ambiguous.length-3} more`:'';
const tot=s.ambiguous.reduce((n,a)=>n+a.count,0);
out.push(['--warn',`<b>${s.ambiguous.length}</b> archive(s) don't name a platform — ${names}${more} — so <b>${fmt(tot)}</b> downloads sit under "Other". Click the <b>Unknown</b> badge in the Releases table to assign one.`]);
}
if(vs.length>=6){
const recent=vs.slice(0,3), older=vs.slice(3,6);
const ra=avg(recent.map(mvPerDay)), oa=avg(older.map(mvPerDay));
if(oa>0){ const chg=Math.round(100*(ra-oa)/oa);
out.push([chg>=0?'--good':'--bad',`Stable-release lifetime averages are <b>${chg>=0?'up':'down'} ${Math.abs(chg)}%</b> — recent releases average ${fmt(Math.round(ra))}/day vs ${fmt(Math.round(oa))}/day before.`]); }
}
const latest=latestStableVersion();
if(latest&&topV){ const adoption=pct(mvTotal(latest),mvTotal(topV)||1);
out.push(['--accent-2',`Latest stable <b>${escapeHtml(latest.version)}</b> has reached <b>${adoption}%</b> of the peak stable release's downloads in ${latest.age} day(s).`]); }
const v=measuredVelocity();
if(v) out.push(['--good',`Measured velocity: <b>${fmt(Math.round(v.perDay))} downloads/day</b> tracked across ${Math.round(v.days)||'<1'} day(s) of your own snapshots.`]);
else out.push(['--warn',`Revisit periodically to unlock measured download activity from snapshot deltas.`]);
$('insGrid').innerHTML=out.map(([c,t])=>`<div class="ins"><span class="dot" style="background:var(${c})"></span><div class="t">${t}</div></div>`).join('');
}
''',
'insights')
# Main chart no longer has pie/month/line-by-version modes. Activity is a true
# snapshot-derived rate; releases are bars because they are cohorts of different ages.
between(
'function baseOpts(showLegend){',
'function onToggleMeta(){',
'''function baseOpts(showLegend){
const grid=cssVar('--border'), tick=cssVar('--muted');
return { responsive:true, maintainAspectRatio:false,
plugins:{ legend:{display:showLegend,position:'right',labels:{color:tick,font:{size:12}}},
tooltip:{callbacks:{label:c=>` ${c.dataset.label||c.label}: ${fmt(c.parsed.y??c.parsed)}`}} },
scales:{ y:{beginAtZero:true,grid:{color:grid},ticks:{color:tick,callback:v=>fmtShort(v)}}, x:{grid:{display:false},ticks:{color:tick,maxRotation:60,minRotation:0}} } };
}
function populateMetricSelect(){
const sel=$('cMetric'), prev=sel.value, activity=$('cGroup').value==='activity';
const suffix=activity?' / day':'';
const opts=[['mode',(MODE_INST()?'Installer downloads':'All-file downloads')+suffix]];
if(!activity) opts.push(['perday',MODE_INST()?'Lifetime installers / day':'Lifetime downloads / day']);
opts.push(['windows','Windows'+suffix],['mac','macOS'+suffix],['linux','Linux'+suffix],['android','Android'+suffix],['other','Other'+suffix]);
if(S.summary && S.summary.hasUpdateChannel)
opts.push(['direct','Direct estimate'+suffix],['inapp','Update-associated'+suffix],['checks','Update checks'+suffix]);
opts.push(['meta','Auto-update traffic'+suffix]);
sel.innerHTML=opts.map(([v,t])=>`<option value="${v}">${t}</option>`).join('');
sel.value = opts.some(o=>o[0]===prev) ? prev : 'mode';
}
function metricOf(v,m){
switch(m){
case 'mode': return mvTotal(v);
case 'perday': return mvPerDay(v);
case 'inapp': return v.inapp;
case 'direct': return v.direct;
case 'checks': return v.checks;
case 'meta': return v.metaTotal;
default: return v[m];
}
}
const SNAP_METRIC={mode:null,perday:null,windows:'Windows',mac:'Mac',linux:'Linux',
android:'Android',other:'Other',meta:'meta',inapp:'inapp',direct:'direct',checks:'checks'};
function onMainGroupChange(){ populateMetricSelect(); drawMain(); }
function drawMain(){
const group=$('cGroup').value, metric=$('cMetric').value;
const label=$('cMetric').selectedOptions[0]?.text||'Downloads';
let labels=[], data=[], type='bar', note='';
if(group==='activity'){
const series=activitySeries(metric); type='line';
labels=series.points.map(p=>p.label); data=series.points.map(p=>p.value);
note = series.points.length
? `Measured from snapshot deltas and normalized to a 24-hour rate${series.granularity==='day'?' (one point per tracked day)':''}. No release-date estimate is used.`
: 'Need at least two usable snapshots at different times to draw download activity.';
} else {
const sorted=[...filteredVersions()].sort((a,b)=>a.date-b.date);
labels=sorted.map(v=>v.version); data=sorted.map(v=>metricOf(v,metric));
note='Bars show each release as its own cohort. They are not connected by a line because newer releases have had less time to accumulate downloads.';
}
if(S.mainChart)S.mainChart.destroy();
const acc=cssVar('--accent');
S.mainChart=new Chart($('mainChart'),{ type,
data:{ labels, datasets:[{ label, data, spanGaps:true,
backgroundColor:type==='bar'?acc:'rgba(88,166,255,.15)',
borderColor:acc,borderWidth:2,fill:type==='line',tension:.28,pointRadius:type==='line'?2:0,pointHoverRadius:5,borderRadius:type==='bar'?4:0 }] },
options:baseOpts(false) });
$('mainNote').textContent=note;
}
''',
'main chart JS')
# Adoption chart focuses on stable releases with actual download data.
between(
'function drawAdopt(){',
'/* ---------- update channel ----------',
'''function drawAdopt(){
const stable=S.versions.filter(v=>!v.prerelease&&mvTotal(v)>0);
const source=stable.length?stable:S.versions.filter(v=>mvTotal(v)>0);
const top=[...source].sort((a,b)=>mvTotal(b)-mvTotal(a)).slice(0,10).reverse();
if(S.adoptChart)S.adoptChart.destroy();
S.adoptChart=new Chart($('adoptChart'),{ type:'bar',
data:{ labels:top.map(v=>v.version), datasets:[{label:'Downloads',data:top.map(mvTotal),backgroundColor:cssVar('--accent-2'),borderRadius:5}] },
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>` ${fmt(c.parsed.x)} downloads · ${mvShare(top[c.dataIndex])}%`}}},
scales:{x:{beginAtZero:true,grid:{color:cssVar('--border')},ticks:{color:cssVar('--muted'),callback:v=>fmtShort(v)}},y:{grid:{display:false},ticks:{color:cssVar('--muted')}}} } });
}
/* ---------- update channel ----------''',
'adoption chart')
# Update-channel charts: the time view uses per-interval installer/payload deltas.
between(
'/* ---------- update channel ----------',
'/* ---------- star history ---------- */',
'''/* ---------- update channel ----------
Time views use snapshot deltas. Lifetime cards remain useful context, while recent rates
answer what is actually happening now. */
const UPD_VERSIONS=15;
function checkVelocitySeries(){ return activitySeries('checks'); }
function recentRate(metric){ const p=activitySeries(metric).points; return p.length?p[p.length-1].value:null; }
function drawUpd(){
const s=S.summary, sec=$('updSection');
sec.classList.toggle('hidden',!s.hasUpdateChannel);
if(!s.hasUpdateChannel){ if(S.updChart){S.updChart.destroy(); S.updChart=null;} return; }
const r=snapRange(), u=(r&&r.upd)||{};
const since=(n)=> r&&n>0 ? `<div class="k-sub up">▲ ${fmt(n)} since ${fmtSnapLabel(r.from.t)}</div>` : '';
const ir=recentRate('inapp'), cr=recentRate('checks'), dr=recentRate('direct');
$('updKpis').innerHTML=`
<div class="kpi"><div class="k-bar" style="background:var(--good)"></div><div class="k-label">In-app updates</div><div class="k-val">${fmt(s.inapp)}</div><div class="k-sub">${ir==null?'paired installer/blockmap estimate':fmtRate(ir)+'/day recent'}</div>${since(u.inapp)}</div>
<div class="kpi"><div class="k-bar" style="background:var(--accent-2)"></div><div class="k-label">Update checks</div><div class="k-val">${fmt(s.kinds.feed)}</div><div class="k-sub">${cr==null?'cumulative feed requests':fmtRate(cr)+'/day recent'}</div>${since(u.checks)}</div>
<div class="kpi"><div class="k-bar" style="background:var(--accent)"></div><div class="k-label">Direct downloads</div><div class="k-val">${fmt(s.direct)}</div><div class="k-sub">${dr==null?'installer residual estimate':fmtRate(dr)+'/day recent'}</div>${since(u.direct)}</div>
<div class="kpi"><div class="k-bar" style="background:var(--warn)"></div><div class="k-label">Update mix</div><div class="k-val">${pct(s.inapp,s.installerTotal)}%<span style="font-size:14px;color:var(--faint)"> in-app</span></div><div class="k-sub">lifetime paired-asset estimate</div></div>`;
const view=$('uView').value;
const good=cssVar('--good'), acc=cssVar('--accent'), a2=cssVar('--accent-2');
let cfg, note='';
if(view==='activity'){
const {points,granularity}=updateActivitySeries();
if(!points.length){ $('uView').value='version'; return drawUpd(); }
cfg={ type:'bar', data:{ labels:points.map(p=>p.label), datasets:[
{label:'Update-associated / day',data:points.map(p=>p.inapp),backgroundColor:good,borderRadius:4},
{label:'Direct estimate / day',data:points.map(p=>p.direct),backgroundColor:acc,borderRadius:4}] }, options:stackedOpts() };
note=`Snapshot-to-snapshot installer and paired payload deltas, normalized per day${granularity==='day'?' and grouped to one point per tracked day':''}. This avoids comparing lifetime blockmap totals against lifetime installer totals across different windows.`;
} else if(view==='checks'){
const {points,granularity}=checkVelocitySeries();
if(!points.length){ $('uView').value='version'; return drawUpd(); }
cfg={ type:'bar', data:{ labels:points.map(p=>p.label), datasets:[{label:'Update checks / day',data:points.map(p=>p.value),backgroundColor:a2,borderRadius:4}] }, options:baseOpts(false) };
note=`Update-check deltas normalized per day${granularity==='day'?' and grouped by tracked day':''}; useful as an activity signal, not a unique-user count.`;
} else if(view==='platform'){
const keys=['Windows','Mac','Linux','Android','Other'].filter(k=>s.byPlat[k].direct+s.byPlat[k].inapp>0);
cfg={ type:'bar', data:{ labels:keys.map(k=>PLAT_LABEL[k]), datasets:[
{label:'In-app updates',data:keys.map(k=>s.byPlat[k].inapp),backgroundColor:good,borderRadius:4},
{label:'Direct downloads',data:keys.map(k=>s.byPlat[k].direct),backgroundColor:acc,borderRadius:4}] }, options:stackedOpts() };
note='Lifetime paired-asset split by platform.';
} else {
const vs=[...filteredVersions()].sort((a,b)=>a.date-b.date).slice(-UPD_VERSIONS);
cfg={ type:'bar', data:{ labels:vs.map(v=>v.version), datasets:[
{label:'In-app updates',data:vs.map(v=>v.inapp),backgroundColor:good,borderRadius:4},
{label:'Direct downloads',data:vs.map(v=>v.direct),backgroundColor:acc,borderRadius:4}] }, options:stackedOpts() };
note='Lifetime paired-asset split by release. Update checks are excluded because /releases/latest/download traffic concentrates on whichever tag currently owns the feed.';
}
if(S.updChart)S.updChart.destroy();
S.updChart=new Chart($('updChart'),cfg);
$('updNote').textContent=note;
}
function stackedOpts(){
const grid=cssVar('--border'), tick=cssVar('--muted');
return { responsive:true, maintainAspectRatio:false, layout:{padding:{top:22}},
plugins:{ legend:{display:true,position:'top',labels:{color:tick,font:{size:12},boxWidth:12}},
stackedTotal:true,
tooltip:{callbacks:{label:c=>` ${c.dataset.label}: ${fmt(c.parsed.y)}`,
footer:items=>`Total: ${fmt(items.reduce((n,it)=>n+it.parsed.y,0))}`}} },
scales:{ x:{stacked:true,grid:{display:false},ticks:{color:tick,maxRotation:60,minRotation:0}},
y:{stacked:true,beginAtZero:true,grid:{color:grid},ticks:{color:tick,callback:v=>fmtShort(v)}} } };
}
''',
'update channel JS')
# Every release-table header is sortable. Version uses numeric-aware ordering, dates
# and measures sort numerically, and Gained respects the selected snapshot range.
between(
'/* ---------- table ---------- */',
'function renderAssetCard',
'''/* ---------- table ---------- */
const COL_TITLE={ Age:'Days since this version was published',
Live:'Stable releases are superseded only by a newer stable release. Prereleases are superseded by the next prerelease or stable release.',
Rate:'Lifetime average since publication; real current activity is shown in Download activity.' };
const VERSION_COLLATOR=new Intl.Collator(undefined,{numeric:true,sensitivity:'base'});
function tableCols(){
const cols=[
{label:'Version',key:'version'},
{label:'Published',key:'published'},
{label:'Age',key:'age',title:COL_TITLE.Age},
{label:'Live',key:'live',title:COL_TITLE.Live},
{label:MODE_INST()?'Installers / day':'Downloads / day',key:'rate',title:COL_TITLE.Rate},
{label:MODE_INST()?'Installers':'All files',key:'downloads'}];
if(S.summary.hasUpdateChannel) cols.push({label:'In-app',key:'inapp'});
cols.push({label:'Gained',key:'gained'},{label:'Share',key:'share'});
return cols;
}
function defaultSortDir(key){ return key==='version'?'asc':'desc'; }
function setTableSort(key){
if(S.tableSort.key===key) S.tableSort.dir=S.tableSort.dir==='asc'?'desc':'asc';
else S.tableSort={key,dir:defaultSortDir(key)};
renderTable();
}
function sortedTableVersions(range,showGain){
const arr=[...filteredVersions()], key=S.tableSort.key, mul=S.tableSort.dir==='asc'?1:-1;
const gain=v=>showGain?(range.delta[v.version]||0):0;
const value=(v)=>{
switch(key){
case 'published': return v.date.getTime();
case 'age': return v.age;
case 'live': return v.days_live;
case 'rate': return mvPerDay(v);
case 'downloads': return mvTotal(v);
case 'inapp': return v.inapp;
case 'gained': return gain(v);
case 'share': return mvShare(v);
default: return 0;
}
};
arr.sort((a,b)=>{
let c=key==='version'?VERSION_COLLATOR.compare(a.version,b.version):(value(a)-value(b));
if(c===0) c=a.date-b.date;
return c*mul;
});
return arr;
}
function renderTable(){
const body=$('relBody'); body.innerHTML='';
const cols=tableCols();
$('relHead').innerHTML=cols.map(c=>{
const active=S.tableSort.key===c.key, arrow=active?(S.tableSort.dir==='asc'?'▲':'▼'):'';
return `<th class="sortable" onclick="setTableSort('${c.key}')"${c.title?` title="${c.title}"`:''}>${c.label}<span class="sort-ind">${arrow}</span></th>`;
}).join('');
const hasUpd=S.summary.hasUpdateChannel;
const range=snapRange();
const showGain=range && !range.noBaseline;
const rangeLabel=range?`${fmtSnapLabel(range.from.t)} → ${fmtSnapLabel(range.to.t)}`:'';
const maxShare=Math.max(1,...S.versions.map(mvShare));
sortedTableVersions(range,showGain).forEach(v=>{
const tr=document.createElement('tr'); tr.className='main';
const share=mvShare(v);
const gain=showGain?(range.delta[v.version]||0):null;
const gainCell=gain===null
? '<span style="color:var(--faint)">—</span>'
: `<span title="${fmt(gain)} downloads between ${escapeHtml(rangeLabel)}" style="color:${gain>0?'var(--good)':'var(--faint)'};font-weight:${gain>0?800:600}">${gain>0?'▲ ':''}${gain>=0?'+':''}${fmt(gain)}</span>`;
const inAppCell=hasUpd
? `<td class="num" style="color:var(--good)">${fmt(v.inapp)}<span style="font-size:11px;color:var(--faint);font-weight:600"> ${pct(v.inapp,v.installerTotal)}%</span></td>`
: '';
tr.innerHTML=`<td><span class="vtag"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>${escapeHtml(v.version)}${v.prerelease?' <span class="pill" style="background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn)">pre</span>':''}</span></td>
<td>${v.date.toISOString().slice(0,10)}</td>
<td>${v.age}d</td>
<td>${v.days_live}d${v.isCurrentChannel?' <span style="color:var(--faint);font-size:11px">(so far)</span>':''}</td>
<td class="num" style="color:var(--accent-2)">${fmt(mvPerDay(v))}</td>
<td class="num" style="color:var(--accent)">${fmt(mvTotal(v))}</td>
${inAppCell}
<td class="num">${gainCell}</td>
<td><div style="display:flex;align-items:center;gap:8px"><div style="flex:1;max-width:70px;height:7px;background:var(--panel-2);border-radius:99px;overflow:hidden"><span style="display:block;height:100%;width:${100*share/maxShare}%;background:var(--accent)"></span></div><span style="font-size:12px;color:var(--muted);font-weight:700">${share}%</span></div></td>`;
const detail=document.createElement('tr'); detail.className='hidden';
const groups=KIND_ORDER.map(k=>[k,v.assets.filter(a=>a.kind===k).sort((a,b)=>b.count-a.count)])
.filter(([,list])=>list.length);
const assets=groups.map(([kind,list])=>
`<div class="kgroup">${KIND_GROUP[kind]}</div>`+list.map(a=>renderAssetCard(v,a,range,showGain,rangeLabel)).join('')
).join('');
detail.innerHTML=`<td colspan="${cols.length}" style="background:var(--panel-2)"><div class="assets">${assets||'No asset data.'}</div></td>`;
tr.onclick=()=>{ tr.classList.toggle('open'); detail.classList.toggle('hidden'); };
body.appendChild(tr); body.appendChild(detail);
});
}
function renderAssetCard''',
'release table')
# Comparison timeline. It reuses normal snapshots when a repo has been analyzed before,
# and records lightweight comparison snapshots for repos only used in the comparison.
between(
'/* ---------- compare ---------- */',
'/* ---------- snapshot info + import/export ---------- */',
'''/* ---------- compare ---------- */
function loadCmp(){ try{return JSON.parse(localStorage.getItem(LS.cmp)||'[]')}catch(e){return []} }
function saveCmp(a){ try{localStorage.setItem(LS.cmp,JSON.stringify(a))}catch(e){} }
function loadCmpHist(){ try{return JSON.parse(localStorage.getItem(LS.cmpHist)||'{}')}catch(e){return {}} }
function saveCmpHist(o){ try{localStorage.setItem(LS.cmpHist,JSON.stringify(o))}catch(e){} }
function snapshotInstallerValue(s){
if(s.inst!=null) return +s.inst;
if(s.plat) return ['Windows','Mac','Linux','Android','Other'].reduce((n,k)=>n+(+s.plat[k]||0),0);
return null;
}
function summarizeReleaseTotals(releases){
resetClassifyCache(); let inst=0,total=0;
releases.forEach(r=>(r.assets||[]).forEach(a=>{
const n=+a.download_count||0; total+=n;
if(classifyAsset(a.name,{hasWindows:true}).kind==='installer') inst+=n;
}));
resetClassifyCache();
return {inst,total};
}
function recordComparePoint(repo,vals){
if(!repo||vals.inst==null||vals.total==null) return;
const all=loadCmpHist(), arr=all[repo]||[], point={t:new Date().toISOString(),inst:+vals.inst,total:+vals.total};
const last=arr[arr.length-1];
if(last && (new Date(point.t)-new Date(last.t))<36e5 && last.inst===point.inst && last.total===point.total) arr[arr.length-1]=point;
else arr.push(point);
all[repo]=arr.slice(-500); saveCmpHist(all);
}
function comparisonSeries(repo){
const snapObj=loadSnaps(), local=(snapObj[repo]||[]).map(s=>({t:s.t,inst:snapshotInstallerValue(s),total:s.total}));
const extra=(loadCmpHist()[repo]||[]).map(p=>({t:p.t,inst:p.inst,total:p.total}));
const seen=new Map();
[...local,...extra].forEach(p=>{
const value=MODE_INST()?p.inst:p.total;
if(value==null||!p.t) return;
seen.set(new Date(p.t).toISOString(),{t:p.t,value:+value});
});
return [...seen.values()].sort((a,b)=>new Date(a.t)-new Date(b.t));
}
async function fetchComparePoint(repo){
let list=loadCmp(), it=list.find(x=>x.repo===repo);
if(it){it.loading=true;saveCmp(list);renderCompare();}
try{
const {all}=await fetchAllReleases(repo), vals=summarizeReleaseTotals(all);
recordComparePoint(repo,vals);
list=loadCmp(); it=list.find(x=>x.repo===repo);
if(it){it.total=MODE_INST()?vals.inst:vals.total;it.loading=false;saveCmp(list);}
}catch(e){
list=loadCmp(); it=list.find(x=>x.repo===repo);
if(it){it.total=-1;it.loading=false;saveCmp(list);}
}
renderCompare();
}
async function addCompare(repoArg){
const repo=(repoArg||$('cmpInput').value.trim()).replace(/^https?:\/\/github\.com\//,'').replace(/\/$/,'');
if(!/^[\w.-]+\/[\w.-]+$/.test(repo)) return;
let list=loadCmp();
if(!list.find(x=>x.repo===repo)){ list.push({repo,total:null,loading:false}); saveCmp(list); }
$('cmpInput').value='';
if(repo===S.repo&&S.summary){
recordComparePoint(repo,{inst:S.summary.installerTotal,total:S.summary.total}); renderCompare();
} else await fetchComparePoint(repo);
}
function addCurrentToCompare(){ if(S.repo) addCompare(S.repo); }
function removeCompare(repo){ saveCmp(loadCmp().filter(x=>x.repo!==repo)); renderCompare(); }
async function refreshCompareRepos(){
const list=loadCmp();
for(const x of list){
if(x.repo===S.repo&&S.summary){ recordComparePoint(x.repo,{inst:S.summary.installerTotal,total:S.summary.total}); continue; }
const hist=loadCmpHist()[x.repo]||[], last=hist[hist.length-1];
if(last && Date.now()-new Date(last.t).getTime()<36e5) continue;
await fetchComparePoint(x.repo);
}
renderCompare();
}
function valueAt(points,t){
let out=null;
for(const p of points){ if(new Date(p.t).getTime()<=t) out=p; else break; }
return out;
}
function closestCompareGap(series){
if(series.length<2) return null;
const times=[...new Set(series.flatMap(s=>s.points.map(p=>new Date(p.t).getTime())))].sort((a,b)=>a-b);
let best=null;
times.forEach(t=>{
const vals=series.map(s=>{const p=valueAt(s.points,t);return p?{repo:s.repo,value:p.value}:null}).filter(Boolean).sort((a,b)=>a.value-b.value);
for(let i=1;i<vals.length;i++){
const gap=Math.abs(vals[i].value-vals[i-1].value);
if(!best||gap<best.gap) best={gap,t,a:vals[i-1].repo,b:vals[i].repo};
}
});
return best;
}
function drawCompareChart(){
const list=loadCmp(), card=$('cmpChartCard');
if(S.cmpChart){S.cmpChart.destroy();S.cmpChart=null;}
if(!list.length){card.classList.add('hidden');return;}
const series=list.map(x=>({repo:x.repo,points:comparisonSeries(x.repo)})).filter(x=>x.points.length);
if(!series.length){card.classList.add('hidden');return;}
card.classList.remove('hidden');
const mode=$('cmpMode')?.value||'absolute';
const colors=['--accent','--good','--warn','--other','--bad','--accent-2','--mac','--android'].map(cssVar);
const datasets=series.map((x,i)=>{
const base=x.points[0].value;
return {label:x.repo,data:x.points.map(p=>({x:new Date(p.t).getTime(),y:mode==='growth'?p.value-base:p.value})),
parsing:false,borderColor:colors[i%colors.length],backgroundColor:'transparent',borderWidth:2.2,tension:.2,pointRadius:2,pointHoverRadius:5,spanGaps:true};
});
const grid=cssVar('--border'),tick=cssVar('--muted');
S.cmpChart=new Chart($('cmpChart'),{type:'line',data:{datasets},options:{responsive:true,maintainAspectRatio:false,
interaction:{mode:'nearest',intersect:false},plugins:{legend:{display:true,position:'top',labels:{color:tick,font:{size:12}}},
tooltip:{callbacks:{title:items=>items.length?new Date(items[0].parsed.x).toLocaleString():'' ,label:c=>` ${c.dataset.label}: ${fmt(c.parsed.y)}`}}},
scales:{x:{type:'linear',grid:{display:false},ticks:{color:tick,callback:v=>new Date(v).toLocaleDateString('en-US',{month:'short',day:'numeric'})}},
y:{beginAtZero:mode==='growth',grid:{color:grid},ticks:{color:tick,callback:v=>fmtShort(v)}}}}});
const best=closestCompareGap(series);
$('cmpNote').textContent=(best?`Closest tracked gap: ${fmt(best.gap)} downloads on ${new Date(best.t).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})} (${best.a} vs ${best.b}). `:'')+
'Each line starts when that repo has a local or comparison snapshot; no earlier history is invented.';
}
function renderCompare(){
const list=loadCmp(), el=$('cmpList');
if(!list.length){ el.innerHTML='<div style="font-size:13px;color:var(--faint)">Add repositories to track their download growth on the same timeline.</div>'; drawCompareChart(); return; }
el.innerHTML=list.map(x=>{
const pts=comparisonSeries(x.repo), latest=pts[pts.length-1];
return `<div class="cmp-item"><span class="ci-name">${escapeHtml(x.repo)}</span><span style="flex:1;font-size:12.5px;color:var(--faint)">${pts.length} snapshot${pts.length===1?'':'s'} tracked</span><span class="ci-val">${x.loading?'…':(x.total<0?'error':(latest?fmt(latest.value):'—'))}</span><span class="ci-x" onclick="removeCompare('${escapeAttr(x.repo)}')">✕</span></div>`;
}).join('');
drawCompareChart();
}
''',
'compare JS')
# Current repo contributes a comparison point every time it is analyzed; other compared
# repos refresh at most once an hour.
once(
' loadStarHistory(repo);\n if(rel.truncated)',
' loadStarHistory(repo);\n recordComparePoint(repo,{inst:S.summary.installerTotal,total:S.summary.total});\n refreshCompareRepos();\n if(rel.truncated)',
'compare refresh in run')
# Snapshot deletion always redraws the time chart, because activity uses snapshots.
once(
" if($('cGroup').value==='snapshot') drawMain();",
" drawMain();",
'snapshot delete redraw')
path.write_text(s, encoding='utf-8')
print(f'Patched {path}: {len(s):,} bytes')
PY
- name: Validate inline JavaScript
shell: bash
run: |
python - <<'PY'
from pathlib import Path
import re
html=Path('index.html').read_text(encoding='utf-8')
blocks=re.findall(r'<script>(.*?)</script>',html,re.S)
if len(blocks)!=1:
raise SystemExit(f'Expected one inline application script, found {len(blocks)}')
Path('/tmp/release-radar.js').write_text(blocks[0],encoding='utf-8')
required=[
'option value="activity"','setTableSort','latestStableVersion','updateActivitySeries',
'drawCompareChart','Closest tracked gap','Lifetime installers / day'
]
missing=[x for x in required if x not in html]
if missing:
raise SystemExit('Missing expected patch markers: '+', '.join(missing))
if '<option value="pie">Pie</option>' in html or '<option value="month">By month</option>' in html:
raise SystemExit('Obsolete pie/month chart option survived patch')
print('Static patch assertions passed')
PY
node --check /tmp/release-radar.js
- name: Commit patched site
shell: bash
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add index.html
if git diff --cached --quiet; then
echo 'No changes to commit'
exit 0
fi
git commit -m 'Improve analytics timelines and release sorting'
git push origin HEAD:main