Add temporary patch fixer #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"> | ||
| <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 & checksum files</div>`; | ||
| else if(!inst && s.metaTotal>0) sub2=`<div class="k-sub">${fmt(s.installerTotal)} installers · ${fmt(s.metaTotal)} auto-update & 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 & 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 | ||