-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
205 lines (182 loc) · 64.3 KB
/
Copy pathindex.html
File metadata and controls
205 lines (182 loc) · 64.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Release Radar — GitHub Release Download Analytics</title>
<meta name="description" content="Track GitHub release downloads, update traffic and real snapshot-based download activity.">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script data-goatcounter="https://imagemetahub.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<style>
:root{--bg:#f6f8fa;--panel:#fff;--panel2:#f0f3f6;--border:#d7dee6;--text:#1a2230;--muted:#5b6b7f;--faint:#8a99ab;--accent:#3b82f6;--accent2:#6366f1;--good:#10b981;--warn:#f59e0b;--bad:#ef4444;--win:#3b82f6;--mac:#64748b;--linux:#f59e0b;--android:#22c55e;--other:#a855f7;--shadow:0 1px 3px rgba(16,24,40,.06);--r:14px}
[data-theme=dark]{--bg:#0d1117;--panel:#161b22;--panel2:#1c232d;--border:#2a323d;--text:#e6edf3;--muted:#9aa7b4;--faint:#6e7d8c;--accent:#58a6ff;--accent2:#818cf8;--good:#3fb950;--warn:#d29922;--bad:#f85149;--win:#58a6ff;--mac:#8b98a5;--linux:#d29922;--android:#3fb950;--other:#bc8cff;--shadow:0 1px 3px rgba(0,0,0,.4)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.wrap{max-width:1180px;margin:auto;padding:20px 18px 80px}.top{display:flex;align-items:center;gap:12px;margin-bottom:22px}.brand{display:flex;align-items:center;gap:11px;font-weight:800;font-size:19px}.logo{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,var(--accent),var(--accent2));display:grid;place-items:center}.logo svg{width:20px}.brand small{display:block;font-size:11px;font-weight:600;color:var(--faint);margin-top:1px}.spacer{flex:1}.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow)}.pad{padding:20px}.iconbtn,.btn{border-radius:10px;height:38px;padding:0 13px;cursor:pointer;font:inherit;font-size:13px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;gap:6px}.iconbtn{background:var(--panel);border:1px solid var(--border);color:var(--muted)}.iconbtn:hover{color:var(--accent);border-color:var(--accent)}.btn{border:0;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;height:41px;padding:0 20px}.btn:disabled{opacity:.6}.search{padding:20px;margin-bottom:22px}.searchrow{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.field{flex:1;min-width:220px}.field label,.ctl label{display:block;color:var(--muted);font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}.field input,.ctl input,.ctl select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:9px;padding:9px 11px;outline:0}.field input{padding:11px 13px;font-size:14px}.field input:focus,.ctl input:focus,.ctl select:focus{border-color:var(--accent)}.token-toggle{font-size:12px;color:var(--accent);cursor:pointer;font-weight:700;display:inline-block;margin-top:10px}.hint{font-size:11.5px;color:var(--faint);line-height:1.5}.hidden{display:none!important}.msg{display:none;margin-top:13px;padding:11px 13px;border-radius:10px;font-size:13px}.msg.err,.msg.warn{display:block;border:1px solid}.msg.err{color:var(--bad);border-color:var(--bad)}.msg.warn{color:var(--warn);border-color:var(--warn)}.chips{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}.chip{background:var(--panel2);border:1px solid var(--border);border-radius:999px;padding:5px 11px;font-size:12px;color:var(--muted);cursor:pointer;font-weight:650}.repohead{display:flex;gap:13px;align-items:center;margin-bottom:18px;flex-wrap:wrap}.repohead img{width:46px;height:46px;border-radius:11px;border:1px solid var(--border)}.rh-name{font-size:18px;font-weight:800}.rh-desc{font-size:13px;color:var(--muted);margin-top:2px}.rh-meta{display:flex;gap:15px;flex-wrap:wrap;color:var(--faint);font-size:12px;margin-top:6px}.rh-meta b{color:var(--text)}.moderow{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}.seg{display:inline-flex;background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:3px;gap:3px}.seg button{border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:750;padding:6px 12px;border-radius:7px;cursor:pointer}.seg button.on{background:var(--accent);color:#fff}.modehint{font-size:12px;color:var(--faint)}.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:13px;margin-bottom:22px}.kpi{position:relative;overflow:hidden;background:var(--panel);border:1px solid var(--border);border-radius:var(--r);padding:16px 17px;box-shadow:var(--shadow)}.kbar{position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent)}.kl{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:.045em}.kv{font-size:27px;font-weight:850;margin-top:6px;line-height:1}.ks{font-size:11.5px;color:var(--faint);font-weight:600;margin-top:7px;line-height:1.35}.ks.up{color:var(--good)}.badge{font-size:9px;font-weight:850;padding:2px 6px;border-radius:5px;text-transform:uppercase}.badge.measured{color:var(--good);background:rgba(16,185,129,.12)}.badge.est{color:var(--warn);background:rgba(245,158,11,.12)}.section{margin-bottom:27px}.section-head{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin-bottom:13px}.section h3,.insights h3{font-size:12.5px;text-transform:uppercase;letter-spacing:.055em;color:var(--muted);font-weight:850;margin:0}.controls{margin-left:auto;display:flex;gap:9px;align-items:flex-end;flex-wrap:wrap}.ctl{min-width:112px}.chart-box{padding:17px}.chart-h{height:340px;position:relative}.chart-h.sm{height:280px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media(max-width:820px){.two{grid-template-columns:1fr}.wrap{padding-left:10px;padding-right:10px}}.insights{margin-bottom:22px}.ins-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:11px;margin-top:12px}.ins{display:flex;gap:10px;background:var(--panel2);border:1px solid var(--border);padding:12px 13px;border-radius:11px;font-size:13px;line-height:1.45}.ins .dot{width:8px;height:8px;border-radius:50%;margin-top:5px;flex:none}.tbl-wrap{overflow:auto;border:1px solid var(--border);border-radius:var(--r);max-height:720px}table{width:100%;border-collapse:collapse;font-size:13px;white-space:nowrap}thead th{position:sticky;top:0;z-index:2;background:var(--panel2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.045em;text-align:left;padding:11px 13px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}thead th:hover{color:var(--text)}.sort-ind{color:var(--accent);margin-left:4px;min-width:8px;display:inline-block}tbody td{padding:10px 13px;border-bottom:1px solid var(--border)}tbody tr.main{cursor:pointer}tbody tr.main:hover{background:var(--panel2)}.vtag{color:var(--accent);font-weight:750;display:flex;align-items:center;gap:6px}.vtag svg{width:13px;transition:transform .15s}tr.open .vtag svg{transform:rotate(180deg)}.num{font-variant-numeric:tabular-nums;font-weight:700}.pill{font-size:9px;font-weight:850;padding:2px 6px;border-radius:999px}.assets{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:9px;padding:5px}.asset{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:10px 11px}.an{font-size:11.5px;color:var(--muted);word-break:break-all;font-weight:650;margin-bottom:7px}.ab{display:flex;align-items:center;justify-content:space-between;gap:8px}.ac{font-size:14px;color:var(--accent);font-weight:800}.split{font-size:10.5px;color:var(--faint);margin-top:6px}.kgroup{grid-column:1/-1;color:var(--faint);font-size:10px;text-transform:uppercase;font-weight:850;letter-spacing:.05em;padding-top:7px}.legend{display:flex;gap:13px;flex-wrap:wrap;margin-top:11px;font-size:11.5px;color:var(--muted)}.legend i{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px}.snap-banner{display:flex;align-items:center;gap:11px;border:1px solid var(--border);background:var(--panel);border-radius:12px;padding:12px 14px;margin-bottom:20px;font-size:12.5px}.cmp-add{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.cmp-add input{flex:1;min-width:220px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:9px;padding:9px 11px}.cmp-list{display:flex;flex-direction:column;gap:8px}.cmp-item{display:flex;align-items:center;gap:10px;background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:9px 12px;font-size:12.5px}.ci-name{font-weight:750;min-width:180px}.ci-x{border:0;background:transparent;cursor:pointer;color:var(--faint);font:inherit;font-weight:800;font-size:16px;line-height:1;padding:5px}.ci-x:hover,.ci-x:focus-visible{color:var(--bad)}.footer{text-align:center;color:var(--faint);font-size:12px;margin-top:38px;line-height:1.6}.spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;display:inline-block;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap">
<div class="top"><div class="brand"><span class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4"><path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/></svg></span><span>Release Radar<small>GitHub download analytics · zero backend</small></span></div><div class="spacer"></div><button class="iconbtn" onclick="toggleTheme()">◐ Theme</button><a class="iconbtn" href="https://github.com/LuqP2/github-release-analytics" target="_blank">Source</a></div>
<div class="card search">
<div class="searchrow"><div class="field"><label>Repository</label><input id="repoInput" placeholder="owner/repo · e.g. LuqP2/image-metahub" onkeydown="if(event.key==='Enter')run()"></div><button class="btn" id="fetchBtn" onclick="run()">Analyze</button></div>
<span class="token-toggle" onclick="$('tokenWrap').classList.toggle('hidden')">+ Add token (higher rate limit / star history)</span>
<div id="tokenWrap" class="hidden" style="margin-top:12px"><div class="field"><label>GitHub Personal Access Token</label><input id="tokenInput" type="password" placeholder="ghp_..." autocomplete="off" oninput="onTokenInput()"></div><label class="hint"><input type="checkbox" id="rememberToken" onchange="onRememberToggle()"> Remember on this device</label><p class="hint">Sent only to api.github.com. By default it remains in memory for this browser session.</p></div>
<div id="recentChips" class="chips"></div><div id="msg" class="msg"></div>
</div>
<div id="results" class="hidden">
<div id="snapBanner"></div><div id="repoHead" class="repohead"></div>
<div class="moderow"><div class="seg"><button id="modeInst" onclick="setMode('inst')">Installers only</button><button id="modeAll" onclick="setMode('all')">All files</button></div><span id="modeHint" class="modehint"></span></div>
<div id="kpis" class="kpis"></div>
<div class="insights"><h3>Insights</h3><div id="insGrid" class="ins-grid"></div></div>
<div class="section">
<div class="section-head"><h3>Download activity</h3><div class="controls"><div class="ctl"><label>View</label><select id="cView" onchange="drawMain()"><option value="activity">Over time</option><option value="release">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" class="chart-note"></div></div>
</div>
<div class="section two">
<div><div class="section-head"><h3>Platform split</h3></div><div class="card chart-box"><div class="chart-h sm"><canvas id="platChart"></canvas></div><div id="platLegend" class="legend"></div></div></div>
<div><div class="section-head"><h3>Stable release downloads</h3></div><div class="card chart-box"><div class="chart-h sm"><canvas id="adoptChart"></canvas></div></div></div>
</div>
<div id="updSection" class="section hidden">
<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 update over time</option><option value="release">By release</option><option value="platform">By platform</option><option value="checks">Update checks over time</option></select></div></div></div>
<div id="updKpis" class="kpis"></div>
<div class="card chart-box"><div class="chart-h"><canvas id="updChart"></canvas></div><div id="updNote" class="chart-note"></div></div>
</div>
<div class="section">
<div class="section-head"><h3>Star history</h3></div><div id="starMsg" class="msg"></div><div id="starStats" class="kpis"></div><div id="starCard" class="card chart-box hidden"><div class="chart-h"><canvas id="starChart"></canvas></div></div>
</div>
<div id="exportContent">
<div class="section">
<div class="section-head"><h3>Releases</h3><div class="controls"><div class="ctl"><label>Search</label><input id="fltSearch" placeholder="version / rc / dev" oninput="renderTable();drawMain()"></div><div class="ctl"><label>From</label><input type="date" id="fltFrom" onchange="renderTable();drawMain()"></div><div class="ctl"><label>To</label><input type="date" id="fltTo" onchange="renderTable();drawMain()"></div><div id="snapRangeCtl" class="controls hidden"><div class="ctl"><label>Gained from</label><select id="cmpFrom" onchange="renderSnapCompare();renderTable()"></select></div><div class="ctl"><label>To</label><select id="cmpTo" onchange="renderSnapCompare();renderTable()"></select></div></div><button class="iconbtn" onclick="clearFilter()">Clear</button><button class="iconbtn" onclick="exportCSV()">CSV</button><button class="iconbtn" onclick="exportJSON()">JSON</button><button class="iconbtn" id="pdfBtn" onclick="exportPDF()">PDF</button></div></div>
<div id="snapCompareResult"></div><div class="tbl-wrap"><table><thead><tr id="relHead"></tr></thead><tbody id="relBody"></tbody></table></div>
</div>
</div>
<div class="section">
<div class="section-head"><h3>Compare repositories</h3><div class="controls"><div class="ctl"><label>View</label><select id="cmpMode" onchange="renderCompare()"><option value="growth">Growth since tracking</option><option value="absolute">Absolute downloads</option></select></div><button class="iconbtn" onclick="addCurrentToCompare()">+ Add current repo</button></div></div>
<div class="cmp-add"><input id="cmpInput" placeholder="owner/repo" onkeydown="if(event.key==='Enter')addCompare()"><button class="iconbtn" onclick="addCompare()">Add</button><button class="iconbtn" onclick="refreshCompareRepos(true)">Refresh</button></div>
<div id="cmpChartCard" class="card chart-box hidden" style="margin-bottom:12px"><div class="chart-h"><canvas id="cmpChart"></canvas></div><div id="cmpNote" class="chart-note"></div></div><div id="cmpList" class="cmp-list"></div>
</div>
<div class="section"><div class="section-head"><h3>Snapshot history</h3></div><div class="card pad"><div id="snapInfo" class="hint"></div><div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px"><button class="iconbtn" onclick="exportSnapshots()">Export history</button><label class="iconbtn">Import history<input type="file" accept="application/json" style="display:none" onchange="importSnapshots(event)"></label><button class="iconbtn" onclick="clearSnapshots()" style="color:var(--bad)">Clear this repo</button></div><div id="snapList" class="cmp-list" style="margin-top:12px;max-height:280px;overflow:auto"></div></div></div>
</div>
<div class="footer">Release Radar · static GitHub release analytics.<br>Snapshots and comparison history stay in this browser unless you export them.</div>
</div>
<script>
const $=id=>document.getElementById(id);
const fmt=n=>n==null?'—':(+n).toLocaleString('en-US',{maximumFractionDigits:1});
const fmt0=n=>n==null?'—':Math.round(+n).toLocaleString('en-US');
const fmtShort=n=>{n=+n||0;if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return''+Math.round(n)};
const pct=(n,d)=>d?Math.round(100*n/d):0;
const DAY=864e5;
const LS={snaps:'rr_snaps_v1',recent:'rr_recent_v1',theme:'rr_theme_v1',token:'rr_token_v1',mode:'rr_mode_v1',rules:'rr_platrules_v1',cmp:'rr_cmp_v1',cmpHist:'rr_cmp_hist_v1'};
const S={repo:null,meta:null,releases:[],versions:[],summary:null,mode:'inst',rules:{},ctx:null,mainChart:null,platChart:null,adoptChart:null,updChart:null,starChart:null,cmpChart:null,tableSort:{key:'published',dir:'desc'},snapSaveError:false};
const PLAT=['Windows','Mac','Linux','Android','Other'];
const PLAT_LABEL={Windows:'Windows',Mac:'macOS',Linux:'Linux',Android:'Android',Other:'Other'};
const PLAT_VAR={Windows:'--win',Mac:'--mac',Linux:'--linux',Android:'--android',Other:'--other'};
const KIND_LABEL={installer:'Installer',payload:'Update payload',feed:'Update check',verify:'Verification',other:'Extra'};
const KIND_GROUP={installer:'Installers',payload:'Update payloads',feed:'Update checks',verify:'Verification',other:'Extras'};
const KIND_ORDER=['installer','payload','feed','verify','other'];
const ZERO_K=()=>({installer:0,payload:0,feed:0,verify:0,other:0});
const ZERO_P=()=>Object.fromEntries(PLAT.map(k=>[k,{direct:0,inapp:0}]));
const MODE_INST=()=>S.mode==='inst';
const mvTotal=v=>MODE_INST()?v.installerTotal:v.total;
const mvShare=v=>MODE_INST()?v.shareInst:v.shareAll;
const mvPerDay=v=>MODE_INST()?v.dpdInst:v.dpdAll;
const sTotal=()=>MODE_INST()?S.summary.installerTotal:S.summary.total;
function cssVar(v){return getComputedStyle(document.documentElement).getPropertyValue(v).trim()}
function escapeHtml(s){return(''+s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
function escapeAttr(s){return escapeHtml(s).replace(/\\/g,'\\\\')}
function toggleTheme(){const t=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',t);try{localStorage.setItem(LS.theme,t)}catch(e){};redrawCharts()}
function redrawCharts(){if(!S.summary)return;drawMain();drawPlat();drawAdopt();drawUpd();renderCompare();if(S.starChart&&S._starSeries)drawStarChart()}
(function(){let t='light';try{t=localStorage.getItem(LS.theme)||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')}catch(e){}document.documentElement.setAttribute('data-theme',t)})();
function headers(){const h={Accept:'application/vnd.github+json'};const t=$('tokenInput').value.trim();if(t)h.Authorization='Bearer '+t;return h}
function onTokenInput(){if($('rememberToken').checked)try{localStorage.setItem(LS.token,$('tokenInput').value)}catch(e){}}
function onRememberToggle(){try{$('rememberToken').checked?localStorage.setItem(LS.token,$('tokenInput').value):localStorage.removeItem(LS.token)}catch(e){}}
function loadStoredToken(){try{const t=localStorage.getItem(LS.token)||'';if(t){$('tokenInput').value=t;$('rememberToken').checked=true;$('tokenWrap').classList.remove('hidden')}}catch(e){}}
function msg(t,k){const el=$('msg');el.textContent=t;el.className='msg '+k}function clearMsg(){$('msg').className='msg';$('msg').textContent=''}
const MULTI=['app.tar.gz','pkg.tar.zst','pkg.tar.xz','app.zip','dsym.zip','tar.gz','tar.xz','tar.bz2','tar.zst'];
const STRONG={apk:'Android',aab:'Android',apks:'Android',dmg:'Mac',pkg:'Mac',mpkg:'Mac','app.zip':'Mac','app.tar.gz':'Mac',exe:'Windows',msi:'Windows',msix:'Windows',msixbundle:'Windows',appx:'Windows',appxbundle:'Windows',nupkg:'Windows',cab:'Windows',appimage:'Linux',deb:'Linux',rpm:'Linux',snap:'Linux',flatpak:'Linux','pkg.tar.zst':'Linux','pkg.tar.xz':'Linux'};
const TOK=[['Android',['android']],['Mac',['mac','mac64','macos','macosx','osx','darwin','apple','silicon']],['Windows',['win','win32','win64','windows','winnt','w32','w64','msvc','mingw','cygwin']],['Linux',['linux','linux64','ubuntu','debian','fedora','centos','rhel','alpine','musl','archlinux','opensuse','raspbian','x11']],['Other',['freebsd','openbsd','netbsd','solaris','haiku','ios','wasm','wasi','source','src']]];
function lc(s){return(''+s).toLowerCase()}function tokens(s){return lc(s).split(/[^a-z0-9]+/).filter(Boolean)}
function extOf(f){for(const e of MULTI)if(f.endsWith('.'+e))return e;const i=f.lastIndexOf('.');return i<0?'':f.slice(i+1)}
function platformOf(name){const f=lc(name).replace(/\.(blockmap|zsync|sig|asc|sha1|sha256|sha512|md5|minisig)$/,'');const ext=extOf(f);if(STRONG[ext])return{cat:STRONG[ext],amb:false};const set=new Set(tokens(f.slice(0,ext?-(ext.length+1):undefined)));for(const[p,words]of TOK)if(words.some(w=>set.has(w)))return{cat:p,amb:false};if(['tar.gz','tar.xz','tar.bz2','tar.zst','tgz','txz','tar'].includes(ext))return{cat:'Linux',amb:false};if(['zip','7z','rar'].includes(ext))return{cat:'Other',amb:true};return{cat:'Other',amb:false}}
function detectArch(f){f=lc(f);if(/(arm64|aarch64|armv8|silicon)/.test(f))return'arm64';if(/universal/.test(f))return'universal';if(/(x86[-_.]?64|amd64|x64|win64)/.test(f))return'x64';if(/(i[36]86|ia32|win32|x86)/.test(f))return'ia32';if(/(armv7l?|armhf|armv6)/.test(f))return'armv7';return null}
function patternOf(name){return name.replace(/\d+(\.\d+)+/g,'*').replace(/\b[0-9a-f]{7,40}\b/gi,'*')}
function loadRules(){try{return JSON.parse(localStorage.getItem(LS.rules)||'{}')}catch(e){return{}}}function saveRules(o){try{localStorage.setItem(LS.rules,JSON.stringify(o))}catch(e){}}
function setRule(name,plat){const all=loadRules(),r=all[S.repo]||{},k=patternOf(name);if(plat)r[k]=plat;else delete r[k];if(Object.keys(r).length)all[S.repo]=r;else delete all[S.repo];saveRules(all);S.rules=r;buildModel(S.releases);renderAll()}
function classify(name,ctx=S.ctx){const f=lc(name);let r;if(f.endsWith('.blockmap'))r={kind:'payload',cat:platformOf(f).cat,base:name.slice(0,-9)};else if(f.endsWith('.zsync'))r={kind:'payload',cat:platformOf(f).cat,base:name.slice(0,-6)};else if(/\.ya?ml$/.test(f)||f==='releases'||/^latest[\w.-]*\.json$/.test(f)){const ts=new Set(tokens(f));let cat=ts.has('mac')||ts.has('darwin')?'Mac':ts.has('linux')?'Linux':ts.has('win')||ts.has('windows')?'Windows':ctx?.hasWindows?'Windows':'Other';r={kind:'feed',cat,base:null}}else if(/\.(sha1|sha256|sha512|md5|sig|asc|pem|minisig|cert|pub)$/.test(f)||/^(sha\d*sums?|checksums?|md5sums?)(\.txt)?$/.test(f))r={kind:'verify',cat:platformOf(f).cat,base:null};else if(/\.(txt|md|json|log|pdb|sym|map)$/.test(f)||f.endsWith('.dsym.zip'))r={kind:'other',cat:platformOf(f).cat,base:null};else{const p=platformOf(f);r={kind:'installer',cat:p.cat,amb:p.amb,base:null}}r.arch=detectArch(f);r.amb=!!r.amb;const ov=S.rules?.[patternOf(name)];if(ov&&r.kind==='installer'){r.cat=ov;r.amb=false;r.overridden=true}return r}
async function fetchAllReleases(repo){let all=[],page=1,truncated=false;while(true){const r=await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,{headers:headers()});if(!r.ok){if(r.status===404)throw Error('NOT_FOUND');if(r.status===403)throw Error('RATE_LIMIT');throw Error('HTTP_'+r.status)}const d=await r.json();if(!d.length)break;all.push(...d);if(d.length<100)break;if(++page>10){truncated=true;break}}return{all,truncated}}
async function fetchMeta(repo){try{const r=await fetch(`https://api.github.com/repos/${repo}`,{headers:headers()});return r.ok?await r.json():null}catch(e){return null}}
async function run(){clearMsg();const repo=$('repoInput').value.trim().replace(/^https?:\/\/github\.com\//,'').replace(/\/$/,'');if(!/^[\w.-]+\/[\w.-]+$/.test(repo)){msg('Enter owner/repo.','err');return}const b=$('fetchBtn');b.disabled=true;b.innerHTML='<span class="spinner"></span> Analyzing';try{const[rr,meta]=await Promise.all([fetchAllReleases(repo),fetchMeta(repo)]);const rel=rr.all.filter(r=>r.assets?.length);if(!rel.length)throw Error('NO_ASSETS');S.repo=repo;S.meta=meta;S.releases=rel;S.rules=loadRules()[repo]||{};buildModel(rel);saveSnapshot();pushRecent(repo);setUrl(repo);renderAll();loadStarHistory(repo);recordComparePoint(repo,{inst:S.summary.installerTotal,total:S.summary.total});refreshCompareRepos(false);if(rr.truncated)msg('Showing the 1000 most recent releases.','warn')}catch(e){const m={NOT_FOUND:'Repository not found.',RATE_LIMIT:'GitHub rate limit hit. Add a token.',NO_ASSETS:'No releases with attached assets.'};msg(m[e.message]||e.message,'err')}finally{b.disabled=false;b.textContent='Analyze'}}
function buildModel(releases){const ctx={hasWindows:false};for(const r of releases)for(const a of r.assets||[]){const c=classify(a.name,null);if(c.kind==='installer'&&c.cat==='Windows')ctx.hasWindows=true}S.ctx=ctx;let vs=releases.map(r=>{const cats=Object.fromEntries(PLAT.map(k=>[k,0])),kinds=ZERO_K(),byPlat=ZERO_P(),assets=[],byName={};for(const a of r.assets||[]){const c=classify(a.name,ctx),x={name:a.name,count:+a.download_count||0,cat:c.cat,kind:c.kind,base:c.base,arch:c.arch,amb:c.amb,overridden:c.overridden,inapp:0,direct:0,payloadFor:null,pairedWith:null};assets.push(x);byName[lc(a.name)]=x;kinds[x.kind]+=x.count;if(x.kind==='installer')cats[x.cat]+=x.count}let clamped=0;for(const p of assets){if(p.kind!=='payload'||!p.base)continue;const inst=byName[lc(p.base)];if(!inst||inst.kind!=='installer')continue;p.payloadFor=inst.name;inst.pairedWith=p.name;inst.inapp=Math.min(inst.count,p.count);if(p.count>inst.count)clamped++}let inapp=0;for(const a of assets)if(a.kind==='installer'){a.direct=a.count-a.inapp;inapp+=a.inapp;byPlat[a.cat].direct+=a.direct;byPlat[a.cat].inapp+=a.inapp}const meta=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,assets,installerTotal:kinds.installer,metaTotal:meta,total:kinds.installer+meta,inapp,direct:kinds.installer-inapp,checks:kinds.feed,clamped}}).filter(v=>!isNaN(v.date)).sort((a,b)=>b.date-a.date);const now=new Date(),sum={installerTotal:0,total:0,metaTotal:0,inapp:0,direct:0,kinds:ZERO_K(),byPlat:ZERO_P(),ambiguous:[]};for(const p of PLAT)sum[p]=0;for(const v of vs){const superseder=vs.filter(x=>x.date>v.date&&(v.prerelease||!x.prerelease)).sort((a,b)=>a.date-b.date)[0]||null;v.isCurrentChannel=!superseder;const liveMs=Math.max(0,+((superseder?.date)||now)-(+v.date));v.days_live=Math.floor(liveMs/DAY);v.age=Math.max(0,Math.floor((now-v.date)/DAY));const liveDays=Math.max(1,v.days_live);v.dpdInst=+(v.installerTotal/liveDays).toFixed(1);v.dpdAll=+(v.total/liveDays).toFixed(1);for(const p of PLAT){sum[p]+=v.cats[p];sum.byPlat[p].direct+=v.byPlat[p].direct;sum.byPlat[p].inapp+=v.byPlat[p].inapp}for(const k in sum.kinds)sum.kinds[k]+=v.kinds[k];sum.installerTotal+=v.installerTotal;sum.metaTotal+=v.metaTotal;sum.total+=v.total;sum.inapp+=v.inapp;sum.direct+=v.direct;for(const a of v.assets)if(a.amb)sum.ambiguous.push(a)}for(const v of vs){v.shareInst=sum.installerTotal?+(100*v.installerTotal/sum.installerTotal).toFixed(1):0;v.shareAll=sum.total?+(100*v.total/sum.total).toFixed(1):0}sum.hasUpdateChannel=sum.kinds.payload>0||sum.kinds.feed>0;S.versions=vs;S.summary=sum}
function loadSnaps(){try{return JSON.parse(localStorage.getItem(LS.snaps)||'{}')}catch(e){return{}}}function saveSnaps(o){try{localStorage.setItem(LS.snaps,JSON.stringify(o));S.snapSaveError=false}catch(e){S.snapSaveError=true}}
function saveSnapshot(){const all=loadSnaps(),arr=all[S.repo]||[],pa={},pv={};for(const v of S.versions){pv[v.version]=v.total;for(const a of v.assets)pa[v.version+'::'+a.name]=a.count}const s=S.summary,x={t:new Date().toISOString(),v:3,total:s.total,inst:s.installerTotal,k:{...s.kinds},d:{direct:s.direct,inapp:s.inapp},plat:Object.fromEntries(PLAT.map(p=>[p,s[p]])),pv,pa};const last=arr[arr.length-1];if(last&&new Date(x.t)-new Date(last.t)<36e5&&last.total===x.total)arr[arr.length-1]=x;else arr.push(x);all[S.repo]=arr.slice(-500);saveSnaps(all)}
function repoSnaps(){return(loadSnaps()[S.repo]||[]).sort((a,b)=>new Date(a.t)-new Date(b.t))}
function deriveSnap(s){if(s.__d)return s.__d;let d;if(s.pa&&Object.keys(s.pa).length){const rows=[],byKey={},k=ZERO_K(),plat=Object.fromEntries(PLAT.map(p=>[p,0]));for(const key in s.pa){const cut=key.indexOf('::');if(cut<0)continue;const tag=key.slice(0,cut),name=key.slice(cut+2),n=+s.pa[key]||0,c=classify(name,S.ctx),r={tag,name,n,c};rows.push(r);byKey[tag+'::'+lc(name)]=r;k[c.kind]+=n;if(c.kind==='installer')plat[c.cat]+=n}let inapp=0;for(const r of rows)if(r.c.kind==='payload'&&r.c.base){const i=byKey[r.tag+'::'+lc(r.c.base)];if(i?.c.kind==='installer')inapp+=Math.min(r.n,i.n)}d={inst:k.installer,total:s.total??Object.values(s.pa).reduce((a,b)=>a+(+b||0),0),k,plat,inapp,direct:k.installer-inapp}}else{const plat=s.plat||{};const inst=s.inst??PLAT.reduce((n,p)=>n+(+plat[p]||0),0);d={inst,total:s.total,k:s.k||null,plat,inapp:s.d?.inapp??null,direct:s.d?.direct??null}}try{Object.defineProperty(s,'__d',{value:d,enumerable:false,configurable:true})}catch(e){}return d}
function snapVal(s,m){const d=deriveSnap(s);if(m==='all')return d.total;if(m==='inst')return d.inst;if(m==='inapp')return d.inapp;if(m==='direct')return d.direct;if(m==='checks')return d.k?.feed??null;if(m==='meta')return d.total!=null&&d.inst!=null?d.total-d.inst:null;if(PLAT.includes(m))return d.plat?.[m]??null;return null}
function snapTotal(s){return MODE_INST()?snapVal(s,'inst'):snapVal(s,'all')}
function measuredVelocity(){const a=repoSnaps().filter(s=>snapTotal(s)!=null);if(a.length<2)return null;const days=(new Date(a.at(-1).t)-new Date(a[0].t))/DAY;if(days<=0)return null;return{gained:snapTotal(a.at(-1))-snapTotal(a[0]),days,perDay:(snapTotal(a.at(-1))-snapTotal(a[0]))/days,count:a.length}}
function lastDelta(){const a=repoSnaps().filter(s=>snapTotal(s)!=null);if(a.length<2)return null;const x=a.at(-2),y=a.at(-1),days=Math.max((new Date(y.t)-new Date(x.t))/DAY,1/24);const gained=snapTotal(y)-snapTotal(x);return{gained,perDay:gained/days,since:x.t}}
function intervalUpdateSplit(a,b){const ap=a.pa||{},bp=b.pa||{};if(!Object.keys(bp).length){const ai=snapVal(a,'inapp'),bi=snapVal(b,'inapp'),ad=snapVal(a,'direct'),bd=snapVal(b,'direct');return{inapp:ai==null||bi==null?null:Math.max(0,bi-ai),direct:ad==null||bd==null?null:Math.max(0,bd-ad)}}const payload=new Map(),install=[];for(const key in bp){const cut=key.indexOf('::');if(cut<0)continue;const tag=key.slice(0,cut),name=key.slice(cut+2),c=classify(name,S.ctx),delta=Math.max(0,(+bp[key]||0)-(+ap[key]||0));if(c.kind==='payload'&&c.base){const k=tag+'::'+lc(c.base);payload.set(k,(payload.get(k)||0)+delta)}else if(c.kind==='installer')install.push({tag,name,delta})}let inapp=0,direct=0;for(const i of install){const p=payload.get(i.tag+'::'+lc(i.name))||0,u=Math.min(i.delta,p);inapp+=u;direct+=i.delta-u}return{inapp,direct}}
function localDayKey(t){const d=new Date(t);return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
function activityEndpoints(){const raw=repoSnaps();if(raw.length<2)return{snaps:raw,granularity:null,omittedToday:false};const days=new Set(raw.map(s=>localDayKey(s.t)));if(days.size>=3){const m=new Map();for(const s of raw)m.set(localDayKey(s.t),s);const today=localDayKey(new Date()),omittedToday=m.delete(today);return{snaps:[...m.values()],granularity:'day',omittedToday}}return{snaps:raw,granularity:'snapshot',omittedToday:false}}
function activitySeries(metric){const{snaps,granularity,omittedToday}=activityEndpoints(),out=[];for(let i=1;i<snaps.length;i++){const a=snaps[i-1],b=snaps[i],days=(new Date(b.t)-new Date(a.t))/DAY;if(days<=0)continue;let delta;if(metric==='direct'||metric==='inapp')delta=intervalUpdateSplit(a,b)[metric];else{const key=metric==='mode'?(MODE_INST()?'inst':'all'):metric,A=snapVal(a,key),B=snapVal(b,key);delta=A==null||B==null?null:Math.max(0,B-A)}if(delta==null)continue;out.push({t:+new Date(b.t),label:new Date(b.t).toLocaleDateString('en-US',{month:'short',day:'numeric',...(granularity==='snapshot'?{hour:'2-digit'}:{})}),value:delta/days})}return{points:out,granularity,omittedToday}}
function snapTagChecks(s,tag){let total=0,found=false;for(const key in s?.pa||{}){const cut=key.indexOf('::');if(cut<0||key.slice(0,cut)!==tag)continue;const name=key.slice(cut+2);if(classify(name,S.ctx).kind!=='feed')continue;found=true;total+=+s.pa[key]||0}return found?total:null}
function estimatedCheckHistory(){const snaps=repoSnaps(),first=snaps[0]||null,cutoff=first?new Date(first.t):new Date(),stable=S.versions.filter(v=>!v.prerelease).sort((a,b)=>a.date-b.date),points=[];let partial=false;for(let i=0;i<stable.length;i++){const v=stable[i],next=stable[i+1]?.date||null,start=v.date,end=next&&next<cutoff?next:cutoff;if(end<=start)continue;let count;if(next&&next<=cutoff)count=v.checks;else if(first)count=snapTagChecks(first,v.version);else count=v.checks;if(count==null){partial=true;continue}const days=Math.max((end-start)/DAY,1/24),rate=count/days;points.push({x:+start,y:rate,version:v.version});points.push({x:+end-1,y:rate,version:v.version})}return{points,cutoff:first?+new Date(first.t):null,partial}}
function updateActivitySeries(){const{snaps,granularity,omittedToday}=activityEndpoints(),out=[];for(let i=1;i<snaps.length;i++){const a=snaps[i-1],b=snaps[i],days=(new Date(b.t)-new Date(a.t))/DAY;if(days<=0)continue;const x=intervalUpdateSplit(a,b);if(x.inapp==null||x.direct==null)continue;out.push({label:new Date(b.t).toLocaleDateString('en-US',{month:'short',day:'numeric',...(granularity==='snapshot'?{hour:'2-digit'}:{})}),inapp:x.inapp/days,direct:x.direct/days})}return{points:out,granularity,omittedToday}}
function latestStable(){return S.versions.find(v=>!v.prerelease)||S.versions[0]||null}
function setMode(m){S.mode=m;try{localStorage.setItem(LS.mode,m)}catch(e){}renderAll()}
function renderAll(){if(!S.summary)return;$('results').classList.remove('hidden');$('modeInst').classList.toggle('on',MODE_INST());$('modeAll').classList.toggle('on',!MODE_INST());$('modeHint').textContent=MODE_INST()?'Installer assets only. Update metadata/checks are excluded from totals.':'Every release asset, including update and verification files.';populateMetric();renderHead();renderSnapBanner();renderKPIs();renderInsights();drawMain();drawPlat();drawAdopt();drawUpd();populateSnapSelects();renderSnapCompare();renderTable();renderSnapInfo();renderSnapList();renderCompare();}
function renderHead(){const m=S.meta;if(!m){$('repoHead').innerHTML='';return}$('repoHead').innerHTML=`<img src="${m.owner?.avatar_url||''}"><div style="flex:1"><div class="rh-name"><a href="${m.html_url}" target="_blank">${escapeHtml(m.full_name||S.repo)}</a></div>${m.description?`<div class="rh-desc">${escapeHtml(m.description)}</div>`:''}<div class="rh-meta"><span>★ <b>${fmt0(m.stargazers_count)}</b> stars</span><span>⑂ <b>${fmt0(m.forks_count)}</b> forks</span>${m.language?`<span><b>${escapeHtml(m.language)}</b></span>`:''}<span><b>${S.versions.length}</b> releases with assets</span></div></div>`}
function renderSnapBanner(){const v=measuredVelocity(),a=repoSnaps();$('snapBanner').innerHTML=v?`<div class="snap-banner"><b>Measured over ${a.length} snapshots:</b> +${fmt0(v.gained)} ${MODE_INST()?'installer downloads':'file downloads'} across ${v.days<1?'<1':fmt(v.days)} days · <b>${fmt(v.perDay)}/day</b>.</div>`:`<div class="snap-banner"><b>Snapshot saved.</b> A second observation at a later time unlocks real download activity and velocity.</div>`}
function kpi(label,val,sub,color='--accent'){return`<div class="kpi"><div class="kbar" style="background:var(${color})"></div><div class="kl">${label}</div><div class="kv">${val}</div><div class="ks">${sub||''}</div></div>`}
function renderKPIs(){const s=S.summary,latest=latestStable(),v=measuredVelocity(),d=lastDelta(),top=PLAT.map(p=>[p,s[p]]).sort((a,b)=>b[1]-a[1])[0];$('kpis').innerHTML=kpi(`Total ${MODE_INST()?'installer downloads':'file downloads'}`,fmt0(sTotal()),d?`${d.gained>=0?'▲':'▼'} ${fmt0(Math.abs(d.gained))} since last snapshot`:'tracking started')+kpi(`Velocity ${v?'<span class="badge measured">measured</span>':'<span class="badge est">estimate</span>'}`,`${fmt(v?v.perDay:latest?mvPerDay(latest):0)}<span style="font-size:13px;color:var(--faint)">/day</span>`,v?'snapshot-based':'latest stable lifetime average','--good')+kpi('Latest stable',latest?escapeHtml(latest.version):'—',latest?`${fmt0(mvTotal(latest))} downloads · ${latest.age}d old`:'','--accent2')+kpi('Top platform',PLAT_LABEL[top[0]],`${pct(top[1],s.installerTotal)}% of installer downloads`,PLAT_VAR[top[0]])}
function renderInsights(){const stable=S.versions.filter(v=>!v.prerelease),vs=stable.length?stable:S.versions,out=[],top=[...vs].sort((a,b)=>mvTotal(b)-mvTotal(a))[0];if(top)out.push(['--accent',`<b>${escapeHtml(top.version)}</b> is the most-downloaded stable release with <b>${fmt0(mvTotal(top))}</b>.`]);if(S.summary.hasUpdateChannel)out.push(['--good',`Lifetime paired-asset counts imply <b>${fmt0(S.summary.inapp)}</b> update-associated and <b>${fmt0(S.summary.direct)}</b> direct installer downloads. Time views use snapshot deltas instead.`]);const latest=latestStable();if(latest&&top)out.push(['--accent2',`Latest stable <b>${escapeHtml(latest.version)}</b> is at <b>${pct(mvTotal(latest),mvTotal(top)||1)}%</b> of the peak stable release after ${latest.age} day(s).`]);if(S.summary.ambiguous.length)out.push(['--warn',`<b>${S.summary.ambiguous.length}</b> installer archive(s) have no platform signal in the filename and are counted as Other.`]);const v=measuredVelocity();if(v)out.push(['--good',`Current measured pace across your history is <b>${fmt(v.perDay)}/day</b>.`]);$('insGrid').innerHTML=out.map(([c,t])=>`<div class="ins"><span class="dot" style="background:var(${c})"></span><div>${t}</div></div>`).join('')}
function baseOpts(legend=false){const grid=cssVar('--border'),tick=cssVar('--muted');return{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:legend,position:'top',labels:{color:tick,boxWidth:12}},tooltip:{callbacks:{label:c=>` ${c.dataset.label||c.label}: ${fmt(c.parsed.y??c.parsed.x??c.parsed)}`}}},scales:{x:{grid:{display:false},ticks:{color:tick,maxRotation:55}},y:{beginAtZero:true,grid:{color:grid},ticks:{color:tick,callback:fmtShort}}}}}
function populateMetric(){const sel=$('cMetric'),old=sel.value,activity=$('cView').value==='activity',suffix=activity?' / day':'';let a=[['mode',(MODE_INST()?'Installer downloads':'All-file downloads')+suffix]];if(S.summary.hasUpdateChannel)a.push(['direct','Direct estimate'+suffix],['inapp','Update-associated'+suffix],['checks','Update checks'+suffix]);a.push(['meta','Update/metadata traffic'+suffix]);sel.innerHTML=a.map(([v,t])=>`<option value="${v}">${t}</option>`).join('');sel.value=a.some(x=>x[0]===old)?old:'mode'}
$('cView').onchange=()=>{populateMetric();drawMain()};
function metricOf(v,m){if(m==='mode')return mvTotal(v);if(m==='direct')return v.direct;if(m==='inapp')return v.inapp;if(m==='checks')return v.checks;if(m==='meta')return v.metaTotal;return 0}
function filteredVersions(){const q=($('fltSearch').value||'').toLowerCase(),f=$('fltFrom').value,t=$('fltTo').value;return S.versions.filter(v=>{const d=v.date.toISOString().slice(0,10);return(!q||v.version.toLowerCase().includes(q)||(v.name||'').toLowerCase().includes(q))&&(!f||d>=f)&&(!t||d<=t)})}
function drawMain(){if(S.mainChart)S.mainChart.destroy();const metric=$('cMetric').value,label=$('cMetric').selectedOptions[0]?.text||'Downloads',activity=$('cView').value==='activity',acc=cssVar('--accent');let labels,data,type,note;if(activity){const s=activitySeries(metric);labels=s.points.map(x=>x.label);data=s.points.map(x=>x.value);type='line';note=s.points.length?`Real snapshot deltas normalized to a 24-hour rate${s.granularity==='day'?' (one point per completed tracked day)':''}.${s.omittedToday?' Today is omitted until the calendar day ends.':''} No release-date estimate is used.`:'Need at least two snapshots at different times.'}else{const a=[...filteredVersions()].sort((x,y)=>x.date-y.date);labels=a.map(v=>v.version);data=a.map(v=>metricOf(v,metric));type='bar';note='Each bar is a release cohort. Bars are intentionally not connected: a fresh release has had less time to accumulate downloads.'}S.mainChart=new Chart($('mainChart'),{type,data:{labels,datasets:[{label,data,backgroundColor:type==='bar'?acc:'rgba(88,166,255,.15)',borderColor:acc,borderWidth:2,fill:type==='line',tension:.28,pointRadius:type==='line'?2:0,borderRadius:4}]},options:baseOpts(false)});$('mainNote').textContent=note}
function drawPlat(){if(S.platChart)S.platChart.destroy();const s=S.summary,keys=PLAT.filter(p=>s[p]>0),vals=keys.map(p=>s[p]),colors=keys.map(p=>cssVar(PLAT_VAR[p]));S.platChart=new Chart($('platChart'),{type:'doughnut',data:{labels:keys.map(p=>PLAT_LABEL[p]),datasets:[{data:vals,backgroundColor:colors,borderColor:cssVar('--panel'),borderWidth:3}]},options:{responsive:true,maintainAspectRatio:false,cutout:'62%',plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>` ${c.label}: ${fmt0(c.parsed)} (${pct(c.parsed,s.installerTotal)}%)`}}}}});$('platLegend').innerHTML=keys.map((p,i)=>`<span><i style="background:${colors[i]}"></i>${PLAT_LABEL[p]} · ${pct(vals[i],s.installerTotal)}%</span>`).join('')}
function drawAdopt(){if(S.adoptChart)S.adoptChart.destroy();const a=S.versions.filter(v=>!v.prerelease&&mvTotal(v)>0).sort((x,y)=>mvTotal(y)-mvTotal(x)).slice(0,12).reverse();S.adoptChart=new Chart($('adoptChart'),{type:'bar',data:{labels:a.map(v=>v.version),datasets:[{label:'Downloads',data:a.map(mvTotal),backgroundColor:cssVar('--accent2'),borderRadius:4}]},options:{...baseOpts(false),indexAxis:'y',scales:{x:{beginAtZero:true,grid:{color:cssVar('--border')},ticks:{color:cssVar('--muted'),callback:fmtShort}},y:{grid:{display:false},ticks:{color:cssVar('--muted')}}}}})}
function stackedOpts(){const o=baseOpts(true);o.scales.x.stacked=true;o.scales.y.stacked=true;return o}
function recentRate(m){const a=activitySeries(m).points;return a.length?a.at(-1).value:null}
function drawUpd(){const s=S.summary,sec=$('updSection');sec.classList.toggle('hidden',!s.hasUpdateChannel);if(!s.hasUpdateChannel)return;if(S.updChart)S.updChart.destroy();const ir=recentRate('inapp'),dr=recentRate('direct'),cr=recentRate('checks');$('updKpis').innerHTML=kpi('Update-associated',fmt0(s.inapp),ir==null?'lifetime paired estimate':`${fmt(ir)}/day recent`,'--good')+kpi('Update checks',fmt0(s.kinds.feed),cr==null?'cumulative feed requests':`${fmt(cr)}/day recent`,'--accent2')+kpi('Direct estimate',fmt0(s.direct),dr==null?'lifetime residual estimate':`${fmt(dr)}/day recent`,'--accent')+kpi('Update mix',pct(s.inapp,s.installerTotal)+'%',`lifetime paired-asset estimate`,'--warn');const view=$('uView').value,good=cssVar('--good'),acc=cssVar('--accent'),a2=cssVar('--accent2');let cfg,note;if(view==='activity'){const x=updateActivitySeries();if(!x.points.length){$('uView').value='release';return drawUpd()}cfg={type:'bar',data:{labels:x.points.map(p=>p.label),datasets:[{label:'Update-associated / day',data:x.points.map(p=>p.inapp),backgroundColor:good},{label:'Direct estimate / day',data:x.points.map(p=>p.direct),backgroundColor:acc}]},options:stackedOpts()};note=`Installer and paired payload deltas between snapshots, normalized per day.${x.omittedToday?' Today is omitted until the calendar day ends.':''} This is an estimate of channel activity, not a unique-user count.`}else if(view==='checks'){const measured=activitySeries('checks'),estimated=estimatedCheckHistory();if(!measured.points.length&&!estimated.points.length){$('uView').value='release';return drawUpd()}const tick=cssVar('--muted'),grid=cssVar('--border'),opts={responsive:true,maintainAspectRatio:false,interaction:{mode:'nearest',intersect:false},plugins:{legend:{display:true,position:'top',labels:{color:tick,boxWidth:12}},tooltip:{callbacks:{title:items=>items.length?new Date(items[0].parsed.x).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}):'',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:true,grid:{color:grid},ticks:{color:tick,callback:fmtShort}}}};cfg={type:'line',data:{datasets:[{label:'Estimated checks / day',data:estimated.points,parsing:false,borderColor:a2,backgroundColor:'transparent',borderWidth:2,borderDash:[6,4],stepped:true,pointRadius:0,spanGaps:false},{label:'Measured checks / day',data:measured.points.map(p=>({x:p.t,y:p.value})),parsing:false,borderColor:a2,backgroundColor:'rgba(99,102,241,.13)',borderWidth:2.4,fill:true,tension:.2,pointRadius:3,pointHoverRadius:5}]},options:opts};const first=repoSnaps()[0];note=`Dashed = estimated before tracking by spreading each stable release's feed count across the time it was current. Solid = measured snapshot deltas${first?`, starting ${new Date(first.t).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'})}`:''}. ${estimated.partial?'Some pre-snapshot intervals could not be reconstructed from the earliest snapshot. ':''}The estimate assumes checks were roughly uniform within each stable-release window; it is not daily telemetry.`}else if(view==='platform'){const keys=PLAT.filter(p=>s.byPlat[p].direct+s.byPlat[p].inapp>0);cfg={type:'bar',data:{labels:keys.map(p=>PLAT_LABEL[p]),datasets:[{label:'Update-associated',data:keys.map(p=>s.byPlat[p].inapp),backgroundColor:good},{label:'Direct estimate',data:keys.map(p=>s.byPlat[p].direct),backgroundColor:acc}]},options:stackedOpts()};note='Lifetime paired-asset split by platform.'}else{const a=[...filteredVersions()].sort((x,y)=>x.date-y.date).slice(-18);cfg={type:'bar',data:{labels:a.map(v=>v.version),datasets:[{label:'Update-associated',data:a.map(v=>v.inapp),backgroundColor:good},{label:'Direct estimate',data:a.map(v=>v.direct),backgroundColor:acc}]},options:stackedOpts()};note='Lifetime paired-asset split by release.'}S.updChart=new Chart($('updChart'),cfg);$('updNote').textContent=note}
async function loadStarHistory(repo){const el=$('starMsg');el.className='msg';$('starStats').innerHTML='';$('starCard').classList.add('hidden');try{const h={Accept:'application/vnd.github.star+json'},t=$('tokenInput').value.trim();if(t)h.Authorization='Bearer '+t;let all=[],page=1;while(page<=60){const r=await fetch(`https://api.github.com/repos/${repo}/stargazers?per_page=100&page=${page}`,{headers:h});if(!r.ok)throw Error('restricted');const d=await r.json();if(!d.length)break;all.push(...d);if(d.length<100)break;page++}if(repo!==S.repo)return;if(!all.length)return;const dates=all.map(x=>new Date(x.starred_at)).sort((a,b)=>a-b),by={};for(const d of dates){const k=d.toISOString().slice(0,10);by[k]=(by[k]||0)+1}let cum=0;S._starSeries=Object.keys(by).sort().map(k=>({x:k,y:cum+=by[k]}));const first=dates[0],last=dates.at(-1),days=Math.max(1,(last-first)/DAY),mx=Object.entries(by).sort((a,b)=>b[1]-a[1])[0];$('starStats').innerHTML=kpi('Total stars',fmt0(dates.length),'timestamped history')+kpi('Average / day',fmt(dates.length/days),'across star history','--good')+kpi('Best day',mx?.[0]||'—',mx?`${mx[1]} stars`:'','--warn');drawStarChart()}catch(e){el.textContent='Full timestamped star history is unavailable without repository access in the supplied token.';el.className='msg warn'}}
function drawStarChart(){if(!S._starSeries)return;$('starCard').classList.remove('hidden');if(S.starChart)S.starChart.destroy();S.starChart=new Chart($('starChart'),{type:'line',data:{labels:S._starSeries.map(x=>x.x),datasets:[{label:'Stars',data:S._starSeries.map(x=>x.y),borderColor:cssVar('--good'),backgroundColor:'rgba(63,185,80,.12)',fill:true,tension:.2,pointRadius:0}]},options:baseOpts(false)})}
const COLL=new Intl.Collator(undefined,{numeric:true,sensitivity:'base'});
function tableCols(){let a=[['Version','version'],['Published','published'],['Age','age'],['Live','live'],[MODE_INST()?'Installers / day':'Downloads / day','rate'],[MODE_INST()?'Installers':'All files','downloads']];if(S.summary.hasUpdateChannel)a.push(['In-app','inapp']);a.push(['Gained','gained'],['Share','share']);return a}
function setSort(k){if(S.tableSort.key===k)S.tableSort.dir=S.tableSort.dir==='asc'?'desc':'asc';else S.tableSort={key:k,dir:k==='version'?'asc':'desc'};renderTable()}
function snapRange(){const a=repoSnaps();if(a.length<2)return null;let i=+$('cmpFrom').value,j=+$('cmpTo').value;if(!a[i]||!a[j]||i===j)return null;let A=a[i],B=a[j];if(new Date(A.t)>new Date(B.t))[A,B]=[B,A];const from=MODE_INST()?derivePvInst(A):(A.pv||{}),to=MODE_INST()?derivePvInst(B):(B.pv||{}),delta={};for(const tag in to)delta[tag]=to[tag]-(from[tag]??0);let assetDelta=null;if(Object.keys(A.pa||{}).length&&Object.keys(B.pa||{}).length){assetDelta={};for(const key in B.pa)assetDelta[key]=(+B.pa[key]||0)-(+A.pa?.[key]||0)}const gained=snapTotal(B)-snapTotal(A),days=(new Date(B.t)-new Date(A.t))/DAY;return{from:A,to:B,delta,assetDelta,gained,perDay:gained/days,days}}
function derivePvInst(s){if(s.__pvi)return s.__pvi;const out={};for(const key in s.pa||{}){const cut=key.indexOf('::');if(cut<0)continue;const tag=key.slice(0,cut),name=key.slice(cut+2),c=classify(name,S.ctx);if(c.kind==='installer')out[tag]=(out[tag]||0)+(+s.pa[key]||0)}try{Object.defineProperty(s,'__pvi',{value:out,enumerable:false})}catch(e){}return out}
function sortedVersions(range){const a=[...filteredVersions()],k=S.tableSort.key,m=S.tableSort.dir==='asc'?1:-1;const val=v=>k==='published'?+v.date:k==='age'?v.age:k==='live'?v.days_live:k==='rate'?mvPerDay(v):k==='downloads'?mvTotal(v):k==='inapp'?v.inapp:k==='gained'?(range?.delta[v.version]||0):k==='share'?mvShare(v):0;a.sort((x,y)=>{let c=k==='version'?COLL.compare(x.version,y.version):val(x)-val(y);if(!c)c=x.date-y.date;return c*m});return a}
function renderTable(){if(!S.summary)return;const cols=tableCols(),range=snapRange();$('relHead').innerHTML=cols.map(([l,k])=>`<th onclick="setSort('${k}')">${l}<span class="sort-ind">${S.tableSort.key===k?(S.tableSort.dir==='asc'?'▲':'▼'):''}</span></th>`).join('');const max=Math.max(1,...S.versions.map(mvShare)),body=$('relBody');body.innerHTML='';for(const v of sortedVersions(range)){const gain=range?(range.delta[v.version]||0):null,tr=document.createElement('tr');tr.className='main';tr.innerHTML=`<td><span class="vtag"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M6 9l6 6 6-6"/></svg>${escapeHtml(v.version)}${v.prerelease?' <span class="pill" style="color:var(--warn);background:rgba(245,158,11,.12)">pre</span>':''}</span></td><td>${v.date.toISOString().slice(0,10)}</td><td>${v.age}d</td><td title="Stable releases are superseded only by a newer stable release. Prereleases use their own channel.">${v.days_live}d${v.isCurrentChannel?' <span style="font-size:10px;color:var(--faint)">(so far)</span>':''}</td><td class="num" title="Lifetime average since publication. Use Download activity for current measured pace.">${fmt(mvPerDay(v))}</td><td class="num" style="color:var(--accent)">${fmt0(mvTotal(v))}</td>${S.summary.hasUpdateChannel?`<td class="num" style="color:var(--good)">${fmt0(v.inapp)} <span style="font-size:10px;color:var(--faint)">${pct(v.inapp,v.installerTotal)}%</span></td>`:''}<td class="num" style="color:${gain>0?'var(--good)':'var(--faint)'}">${gain==null?'—':(gain>0?'▲ +':'')+fmt0(gain)}</td><td><span style="display:inline-block;width:55px;height:6px;background:var(--panel2);border-radius:9px;overflow:hidden"><i style="display:block;width:${100*mvShare(v)/max}%;height:100%;background:var(--accent)"></i></span> ${mvShare(v)}%</td>`;const det=document.createElement('tr');det.className='hidden';const groups=KIND_ORDER.map(k=>[k,v.assets.filter(a=>a.kind===k).sort((a,b)=>b.count-a.count)]).filter(x=>x[1].length);det.innerHTML=`<td colspan="${cols.length}" style="background:var(--panel2)"><div class="assets">${groups.map(([k,list])=>`<div class="kgroup">${KIND_GROUP[k]}</div>`+list.map(a=>assetCard(v,a,range)).join('')).join('')}</div></td>`;tr.onclick=()=>{tr.classList.toggle('open');det.classList.toggle('hidden')};body.append(tr,det)}}
function assetCard(v,a,range){let badge=a.kind==='installer'?(a.amb?'Unknown':PLAT_LABEL[a.cat]):KIND_LABEL[a.kind],color=a.kind==='installer'?(a.amb?'--warn':PLAT_VAR[a.cat]):a.kind==='payload'?'--good':a.kind==='feed'?'--accent2':'--muted';if(a.kind==='installer'&&a.arch)badge+=' · '+a.arch;let extra='',assetGain=range?.assetDelta?.[v.version+'::'+a.name];if(assetGain!=null)extra+=`<div class="split" style="color:${assetGain>0?'var(--good)':'var(--faint)'}">${assetGain>0?'▲ +':''}${fmt0(assetGain)} gained in selected range</div>`;if(a.kind==='installer'&&a.pairedWith)extra+=`<div class="split">${fmt0(a.inapp)} update-associated · ${fmt0(a.direct)} direct estimate</div>`;if(a.kind==='payload')extra+=`<div class="split">${a.payloadFor?'paired with '+escapeHtml(a.payloadFor):'no matching installer'}</div>`;const select=a.kind==='installer'?` onclick="event.stopPropagation();platformPicker(this,'${escapeAttr(a.name)}')" style="cursor:pointer;color:var(${color});background:color-mix(in srgb,var(${color}) 15%,transparent)"`:` style="color:var(${color});background:color-mix(in srgb,var(${color}) 15%,transparent)"`;return`<div class="asset"><div class="an">${escapeHtml(a.name)}</div><div class="ab"><span class="ac">${fmt0(a.count)}</span><span class="pill"${select}>${badge}</span></div>${extra}</div>`}
function platformPicker(el,name){const cur=S.rules[patternOf(name)]||'',opts=[['','Auto'],...PLAT.map(x=>[x,PLAT_LABEL[x]])];el.outerHTML=`<select onclick="event.stopPropagation()" onchange="event.stopPropagation();setRule('${escapeAttr(name)}',this.value)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px">${opts.map(([v,t])=>`<option value="${v}"${v===cur?' selected':''}>${t}</option>`).join('')}</select>`}
function clearFilter(){$('fltSearch').value='';$('fltFrom').value='';$('fltTo').value='';renderTable();drawMain()}
function populateSnapSelects(){const a=repoSnaps(),opts=a.map((s,i)=>`<option value="${i}">${new Date(s.t).toLocaleString()}</option>`).join('');$('cmpFrom').innerHTML=opts;$('cmpTo').innerHTML=opts;if(a.length>=2){$('snapRangeCtl').classList.remove('hidden');$('cmpFrom').value=a.length-2;$('cmpTo').value=a.length-1}else $('snapRangeCtl').classList.add('hidden')}
function renderSnapCompare(){const r=snapRange();$('snapCompareResult').innerHTML=r?`<div class="hint" style="margin-bottom:9px"><b>${r.gained>=0?'+':''}${fmt0(r.gained)}</b> ${MODE_INST()?'installer downloads':'file downloads'} between ${new Date(r.from.t).toLocaleString()} and ${new Date(r.to.t).toLocaleString()} · ${fmt(r.perDay)}/day.</div>`:''}
function renderSnapInfo(){const a=repoSnaps();$('snapInfo').innerHTML=`<b>${a.length}</b> snapshot${a.length===1?'':'s'} for <b>${escapeHtml(S.repo)}</b>${a.length?`, ${new Date(a[0].t).toLocaleString()} → ${new Date(a.at(-1).t).toLocaleString()}`:''}.${S.snapSaveError?'<div style="color:var(--bad)">Latest snapshot could not be saved: localStorage is full.</div>':''}`}
function renderSnapList(){const a=repoSnaps();$('snapList').innerHTML=a.map((s,i)=>`<div class="cmp-item"><span class="ci-name">${new Date(s.t).toLocaleString()}</span><span style="flex:1;color:var(--faint)">${fmt0(snapTotal(s))} downloads</span><span class="ci-x" onclick="deleteSnapshot(${i})">✕</span></div>`).reverse().join('')}
function deleteSnapshot(i){const all=loadSnaps(),a=all[S.repo]||[];a.splice(i,1);all[S.repo]=a;saveSnaps(all);renderAll()}
function clearSnapshots(){if(!confirm('Clear snapshot history for this repo?'))return;const all=loadSnaps();delete all[S.repo];saveSnaps(all);saveSnapshot();renderAll()}
function exportSnapshots(){download(`release-radar-snapshots-${S.repo.replace('/','-')}.json`,JSON.stringify({repo:S.repo,exported:new Date().toISOString(),snapshots:repoSnaps()},null,2),'application/json')}
function importSnapshots(ev){const f=ev.target.files[0];if(!f)return;const rd=new FileReader();rd.onload=()=>{try{const j=JSON.parse(rd.result),repo=j.repo||S.repo,incoming=j.snapshots||j;if(!Array.isArray(incoming))throw 0;const all=loadSnaps(),merged=[...(all[repo]||[]),...incoming].filter((s,i,a)=>a.findIndex(x=>x.t===s.t)===i).sort((a,b)=>new Date(a.t)-new Date(b.t));all[repo]=merged.slice(-500);saveSnaps(all);if(repo===S.repo)renderAll()}catch(e){msg('Could not import snapshot file.','err')}};rd.readAsText(f);ev.target.value=''}
function loadCmp(){try{const raw=JSON.parse(localStorage.getItem(LS.cmp)||'[]'),a=Array.isArray(raw)?raw:[],repos=[...new Set(a.map(x=>typeof x==='string'?x:x?.repo||x?.full_name||'').filter(x=>typeof x==='string'&&/^[\w.-]+\/[\w.-]+$/.test(x)))];if(JSON.stringify(a)!==JSON.stringify(repos))saveCmp(repos);return repos}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 recordComparePoint(repo,v){if(!repo||v.inst==null||v.total==null)return;const all=loadCmpHist(),a=all[repo]||[],p={t:new Date().toISOString(),inst:+v.inst,total:+v.total},last=a.at(-1);if(last&&new Date(p.t)-new Date(last.t)<36e5&&last.inst===p.inst&&last.total===p.total)a[a.length-1]=p;else a.push(p);all[repo]=a.slice(-500);saveCmpHist(all)}
function comparisonSeries(repo){const normal=(loadSnaps()[repo]||[]).map(s=>({t:s.t,inst:s.inst??PLAT.reduce((n,p)=>n+(+s.plat?.[p]||0),0),total:s.total})),extra=(loadCmpHist()[repo]||[]),m=new Map();for(const p of [...normal,...extra]){const val=MODE_INST()?p.inst:p.total;if(p.t&&val!=null)m.set(new Date(p.t).toISOString(),{t:p.t,value:+val})}return[...m.values()].sort((a,b)=>new Date(a.t)-new Date(b.t))}
function summarizeReleases(rels){let inst=0,total=0;const ctx={hasWindows:true};for(const r of rels)for(const a of r.assets||[]){const n=+a.download_count||0,c=classify(a.name,ctx);total+=n;if(c.kind==='installer')inst+=n}return{inst,total}}
async function addCompare(repoArg){const repo=(repoArg||$('cmpInput').value.trim()).replace(/^https?:\/\/github\.com\//,'').replace(/\/$/,'');if(!/^[\w.-]+\/[\w.-]+$/.test(repo))return;let a=loadCmp();if(!a.includes(repo)){a.push(repo);saveCmp(a)}$('cmpInput').value='';if(repo===S.repo)recordComparePoint(repo,{inst:S.summary.installerTotal,total:S.summary.total});else try{const rr=await fetchAllReleases(repo);recordComparePoint(repo,summarizeReleases(rr.all))}catch(e){}renderCompare()}
function addCurrentToCompare(){if(S.repo)addCompare(S.repo)}function removeCompare(repo){saveCmp(loadCmp().filter(x=>x!==repo));const history=loadCmpHist();delete history[repo];saveCmpHist(history);renderCompare()}
async function refreshCompareRepos(force){for(const repo of loadCmp()){if(repo===S.repo){recordComparePoint(repo,{inst:S.summary.installerTotal,total:S.summary.total});continue}const h=loadCmpHist()[repo]||[],last=h.at(-1);if(!force&&last&&Date.now()-new Date(last.t)<36e5)continue;try{const rr=await fetchAllReleases(repo);recordComparePoint(repo,summarizeReleases(rr.all))}catch(e){}}renderCompare()}
function valueAt(a,t){let x=null;for(const p of a){if(new Date(p.t)<=t)x=p;else break}return x}function closestGap(series){if(series.length<2)return null;const times=[...new Set(series.flatMap(s=>s.points.map(p=>+new Date(p.t))))].sort((a,b)=>a-b);let best=null;for(const t of times){const vals=series.map(s=>{const p=valueAt(s.points,t);return p?{repo:s.repo,v:p.value}:null}).filter(Boolean).sort((a,b)=>a.v-b.v);for(let i=1;i<vals.length;i++){const gap=vals[i].v-vals[i-1].v;if(!best||gap<best.gap)best={gap,t,a:vals[i-1].repo,b:vals[i].repo}}}return best}
function renderCompare(){const repos=loadCmp(),list=$('cmpList'),card=$('cmpChartCard');if(S.cmpChart){S.cmpChart.destroy();S.cmpChart=null}if(!repos.length){list.innerHTML='<div class="hint">Add repositories to build a shared growth timeline.</div>';card.classList.add('hidden');return}const series=repos.map(repo=>({repo,points:comparisonSeries(repo)}));list.innerHTML=series.map(s=>`<div class="cmp-item"><span class="ci-name">${escapeHtml(s.repo)}</span><span style="flex:1;color:var(--faint)">${s.points.length} snapshot${s.points.length===1?'':'s'}</span><b>${s.points.length?fmt0(s.points.at(-1).value):'—'}</b><button type="button" class="ci-x" aria-label="Remove ${escapeAttr(s.repo)} from comparison" onclick="removeCompare('${escapeAttr(s.repo)}')">✕</button></div>`).join('');const useful=series.filter(s=>s.points.length);if(!useful.length){card.classList.add('hidden');return}card.classList.remove('hidden');const growth=$('cmpMode').value==='growth',colors=['--accent','--good','--warn','--other','--bad','--accent2','--mac','--android'].map(cssVar),datasets=useful.map((s,i)=>{const base=s.points[0].value;return{label:s.repo,data:s.points.map(p=>({x:+new Date(p.t),y:growth?p.value-base:p.value})),parsing:false,borderColor:colors[i%colors.length],backgroundColor:'transparent',borderWidth:2.2,tension:.2,pointRadius:2}}),tick=cssVar('--muted'),grid=cssVar('--border');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,boxWidth:12}},tooltip:{callbacks:{title:a=>a.length?new Date(a[0].parsed.x).toLocaleString():'',label:c=>` ${c.dataset.label}: ${fmt0(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:growth,grid:{color:grid},ticks:{color:tick,callback:fmtShort}}}}});const best=closestGap(useful);$('cmpNote').textContent=(best?`Closest tracked gap: ${fmt0(best.gap)} downloads on ${new Date(best.t).toLocaleDateString()} (${best.a} vs ${best.b}). `:'')+'Lines start with the first local snapshot available for each repo; no earlier history is invented.'}
function loadRecent(){try{return JSON.parse(localStorage.getItem(LS.recent)||'[]')}catch(e){return[]}}function pushRecent(repo){let a=loadRecent().filter(x=>x!==repo);a.unshift(repo);a=a.slice(0,6);try{localStorage.setItem(LS.recent,JSON.stringify(a))}catch(e){}renderRecent()}function renderRecent(){const a=loadRecent();$('recentChips').innerHTML=a.length?'<span class="hint">Recent:</span>'+a.map(r=>`<span class="chip" onclick="$('repoInput').value='${escapeAttr(r)}';run()">${escapeHtml(r)}</span>`).join(''):''}
function exportCSV(){const rows=[['version','published','prerelease','age_days','days_live','lifetime_installers_per_day','installers','inapp_estimate','direct_estimate','update_checks','all_files','share_installers_pct']];for(const v of S.versions)rows.push([v.version,v.date.toISOString(),v.prerelease,v.age,v.days_live,v.dpdInst,v.installerTotal,v.inapp,v.direct,v.checks,v.total,v.shareInst]);download(`${S.repo.replace('/','-')}-downloads.csv`,rows.map(r=>r.map(x=>`"${(''+x).replace(/"/g,'""')}"`).join(',')).join('\n'),'text/csv')}
function exportJSON(){download(`${S.repo.replace('/','-')}-downloads.json`,JSON.stringify({repo:S.repo,generated:new Date().toISOString(),countingMode:S.mode,summary:S.summary,versions:S.versions},null,2),'application/json')}
function exportPDF(){if(typeof html2pdf==='undefined')return;const b=$('pdfBtn');b.disabled=true;html2pdf().set({margin:7,filename:`${S.repo.replace('/','-')}-report.pdf`,html2canvas:{scale:1.25,backgroundColor:cssVar('--bg')},jsPDF:{unit:'mm',format:'a4',orientation:'landscape'}}).from($('exportContent')).save().finally(()=>b.disabled=false)}
function download(name,content,type){const b=new Blob([content],{type}),u=URL.createObjectURL(b),a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000)}function setUrl(repo){try{const u=new URL(location);u.searchParams.set('repo',repo);history.replaceState(null,'',u)}catch(e){}}
function selfTest(){const C=[['Joplin-Setup-3.7.9.exe','installer','Windows'],['Joplin-Setup-3.7.9.exe.blockmap','payload','Windows'],['Joplin-3.7.9-arm64.DMG','installer','Mac'],['Joplin-3.7.9-mac.zip','installer','Mac'],['Joplin-3.7.9.AppImage','installer','Linux'],['latest.yml','feed','Windows'],['latest-mac.yml','feed','Mac'],['latest-linux.yml','feed','Linux'],['gh_2.96.0_windows_amd64.zip','installer','Windows'],['gh_2.96.0_linux_amd64.tar.gz','installer','Linux'],['Ollama-darwin.zip','installer','Mac'],['app-1.0.0.apk','installer','Android'],['Image.MetaHub-0.18.1-win-x64.zip','installer','Windows'],['ImageMetaHub-Portable-0.19.0-x64.exe','installer','Windows']];const old=S.ctx;S.ctx={hasWindows:true};const fails=[];for(const[n,k,p]of C){const r=classify(n,S.ctx);if(r.kind!==k||r.cat!==p)fails.push({n,expected:k+'/'+p,got:r.kind+'/'+r.cat})}S.ctx=old;fails.length?console.table(fails):console.log(`classifier self-test: ${C.length}/${C.length} passed`);return fails}
(function init(){try{S.mode=localStorage.getItem(LS.mode)==='all'?'all':'inst'}catch(e){}loadStoredToken();renderRecent();if(location.hash==='#selftest')selfTest();const p=new URLSearchParams(location.search).get('repo');if(p){$('repoInput').value=p;run()}})();
</script>
</body>
</html>