index.html 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>IRC channel stats</title>
  7. <!--
  8. pisg landing page: one static file, no PHP, no cron, no libraries.
  9. pisg writes channels.json next to each channel page (option ChannelIndex): for every channel its
  10. totals, a 24-hour profile, the last 30 days and the top talkers. This page turns that into the
  11. statistics of the statistics: what all channels add up to, how they compare, and a link to each
  12. channel's own page. Copy this file into the folder your channel pages are written to, next to
  13. channels.json. It works on any static host, including GitHub Pages.
  14. Change the two lines marked EDIT below to rename the page.
  15. -->
  16. <style>
  17. :root {
  18. --bg: #f6f7f9; --surface: #ffffff; --fg: #171b21; --muted: #59626d; --line: #e0e3e8; --line2: #c6ccd4; --hover: #eceff3;
  19. --mark: #2a78d6; --t0: #4a3aa7; --t1: #eda100; --t2: #1baf7a; --t3: #eb6834;
  20. color-scheme: light dark;
  21. }
  22. @media (prefers-color-scheme: dark) {
  23. :root { --bg: #0f1318; --surface: #171c23; --fg: #e8ebef; --muted: #98a2ae; --line: #252c35; --line2: #38414c; --hover: #1b2129;
  24. --mark: #3987e5; --t0: #9085e9; --t1: #c98500; --t2: #199e70; --t3: #d95926; }
  25. }
  26. * { box-sizing: border-box; }
  27. html { -webkit-text-size-adjust: 100%; }
  28. body {
  29. margin: 0; padding: 32px 24px 64px; background: var(--bg); color: var(--fg);
  30. font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  31. }
  32. main { max-width: 1240px; margin: 0 auto; }
  33. :root { --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
  34. a { color: inherit; }
  35. a:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
  36. [hidden] { display: none !important; }
  37. .num { font-variant-numeric: tabular-nums; }
  38. h1 { margin: 4px 0 6px; font: 700 clamp(24px, 4.6vw, 40px)/1.15 var(--mono); letter-spacing: -0.035em; overflow-wrap: anywhere; }
  39. .lead { margin: 0 0 8px; color: var(--muted); }
  40. /* headline sentence and facts: same shape as the overview on the channel pages */
  41. .hero { margin: 28px 0 18px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 600; text-wrap: balance; }
  42. .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 48px; margin: 0; }
  43. .fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
  44. .fact dt { color: var(--muted); }
  45. .fact dd { margin: 0; text-align: right; font-family: var(--mono); font-weight: 600; overflow-wrap: anywhere; }
  46. .fact small { display: block; font: 400 12px/1.3 system-ui, sans-serif; color: var(--muted); }
  47. section { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line2); }
  48. h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; }
  49. .intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
  50. /* one row per channel */
  51. .cols, .row { display: grid; gap: 8px 24px; align-items: center;
  52. grid-template-columns: minmax(150px, 1.15fr) minmax(190px, 1.5fr) minmax(150px, 1.1fr) minmax(200px, 1.5fr) minmax(120px, .8fr); }
  53. .cols { padding: 8px 0; border-bottom: 1px solid var(--line2); color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
  54. .row { padding: 14px 0; border-bottom: 1px solid var(--line); }
  55. .row:hover { background: var(--hover); }
  56. .name > a:first-child { font: 700 17px/1.2 var(--mono); letter-spacing: -0.02em; text-decoration-color: var(--line2); text-underline-offset: 4px; overflow-wrap: anywhere; }
  57. .name > a:first-child:hover { text-decoration-color: currentColor; }
  58. .btn, .btn:link, .btn:visited { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
  59. background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
  60. .btn:hover { background: var(--bg); border-color: var(--fg); }
  61. .btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
  62. .sub { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
  63. .big { font: 600 15px/1.2 var(--mono); }
  64. .track { height: 8px; margin: 6px 0 4px; border-radius: 4px; background: var(--line); overflow: hidden; }
  65. .fill { height: 100%; border-radius: 4px; background: var(--mark); }
  66. .what { font-family: var(--mono); font-weight: 600; }
  67. /* 24-hour profile: one bar per hour, coloured by time of day like the channel pages */
  68. .hours { display: flex; align-items: flex-end; gap: 2px; height: 38px; }
  69. .hours i { flex: 1 1 0; min-width: 0; border-radius: 2px 2px 0 0; }
  70. .hours .h0 { background: var(--t0); } .hours .h1 { background: var(--t1); }
  71. .hours .h2 { background: var(--t2); } .hours .h3 { background: var(--t3); }
  72. .hticks { display: flex; justify-content: space-between; margin-top: 3px; color: var(--muted); font: 11px/1 var(--mono); }
  73. .spark { display: block; width: 100%; max-width: 170px; height: 38px; }
  74. .spark rect { fill: var(--mark); }
  75. .legend { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 14px 0 0; color: var(--muted); font-size: 12px; }
  76. .legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; background: var(--c); }
  77. /* who talks most, per channel */
  78. .talkers { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 28px 40px; margin-top: 18px; }
  79. .talkers h3 { margin: 0 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line2); font: 700 15px/1.2 var(--mono); }
  80. .talkers ol { margin: 0; padding: 0; list-style: none; }
  81. .talkers li { display: grid; grid-template-columns: minmax(0, 1fr) 64px auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
  82. .talkers li b { font: 600 14px/1.2 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  83. .talkers li .track { margin: 0; }
  84. .talkers li span { color: var(--muted); font-size: 13px; }
  85. .msg { margin-top: 24px; padding: 14px 0; border-top: 1px solid var(--line2); color: var(--muted); }
  86. .msg strong { color: var(--fg); }
  87. code { padding: .1em .35em; border-radius: 4px; background: var(--surface); border: 1px solid var(--line); font-size: .9em; }
  88. footer { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
  89. /* narrower: each channel becomes a small block, the column titles go away */
  90. @media (max-width: 980px) {
  91. .cols { display: none; }
  92. .row { grid-template-columns: 1fr 1fr; padding: 18px 0; }
  93. .row > .name { grid-column: 1 / -1; }
  94. .row > .profile { grid-column: 1 / -1; }
  95. }
  96. @media (max-width: 520px) {
  97. body { padding: 24px 16px 48px; }
  98. .row { grid-template-columns: 1fr; }
  99. .fact { gap: 10px; }
  100. }
  101. </style>
  102. </head>
  103. <body>
  104. <main>
  105. <h1 id="site-title">IRC channel stats</h1>
  106. <p class="lead" id="site-lead">What the channels add up to, and where to read more about each one.</p>
  107. <div id="overview" hidden>
  108. <p class="hero" id="hero"></p>
  109. <dl class="facts" id="facts"></dl>
  110. </div>
  111. <section id="channels" hidden>
  112. <h2>Channels</h2>
  113. <p class="intro">Every channel that has a statistics page, busiest first. Open one for the full page.</p>
  114. <div class="cols" aria-hidden="true">
  115. <div>Channel</div><div>Lines</div><div>Busiest hour</div><div>Through the day</div><div>Last 30 days</div>
  116. </div>
  117. <div id="rows"></div>
  118. <p class="legend" aria-hidden="true">
  119. <span style="--c: var(--t0)">night 0&ndash;5</span><span style="--c: var(--t1)">morning 6&ndash;11</span>
  120. <span style="--c: var(--t2)">afternoon 12&ndash;17</span><span style="--c: var(--t3)">evening 18&ndash;23</span>
  121. </p>
  122. </section>
  123. <section id="people" hidden>
  124. <h2>Who talks most</h2>
  125. <p class="intro">The five busiest people in each channel (bots left out where pisg knows them), with their share of the line count shown against the busiest of the five.</p>
  126. <div class="talkers" id="talkers"></div>
  127. </section>
  128. <div class="msg" id="status" role="status">Loading channels&hellip;</div>
  129. <noscript><div class="msg">This page needs JavaScript to list the channels.</div></noscript>
  130. <footer>Statistics generated by <a href="https://github.com/PISG/pisg">pisg</a></footer>
  131. </main>
  132. <script>
  133. "use strict";
  134. // EDIT: the heading and the sentence under it
  135. var SITE_TITLE = "IRC channel stats";
  136. var SITE_LEAD = "What the channels add up to, and where to read more about each one.";
  137. // Where pisg writes the channel list (pisg option ChannelIndex), next to this page.
  138. var INDEX_URL = "channels.json";
  139. var $ = function (id) { return document.getElementById(id); };
  140. function el(tag, cls, text) {
  141. var e = document.createElement(tag);
  142. if (cls) e.className = cls;
  143. if (text !== undefined && text !== null) e.textContent = text; // textContent: names are never parsed as HTML
  144. return e;
  145. }
  146. document.title = SITE_TITLE;
  147. $("site-title").textContent = SITE_TITLE;
  148. $("site-lead").textContent = SITE_LEAD;
  149. function say(html) { $("status").hidden = false; $("status").innerHTML = html; }
  150. function clearSay() { $("status").hidden = true; }
  151. // The channel list is data pisg wrote, but treat it as untrusted anyway: only accept a
  152. // plain relative .html path, so a bad entry can never become a javascript: or offsite URL.
  153. function safeFile(f) {
  154. return typeof f === "string" && /^[\w.\-]+(\/[\w.\-]+)*\.html?$/i.test(f) && f.indexOf("..") === -1;
  155. }
  156. function norm(s) {
  157. s = String(s).replace(/^#+/, "").toLowerCase();
  158. return s.normalize ? s.normalize("NFD").replace(/[̀-ͯ]/g, "") : s;
  159. }
  160. function whole(v) { return typeof v === "number" && isFinite(v) ? v : 0; }
  161. function list(v, n) { return Array.isArray(v) && (n === undefined || v.length === n) ? v.map(whole) : null; }
  162. function fmt(n) { return Math.round(n).toLocaleString(); }
  163. function fmt1(n) { return n >= 100 ? fmt(n) : (Math.round(n * 10) / 10).toLocaleString(); }
  164. function pct(n, d) { return d ? (Math.round(1000 * n / d) / 10).toLocaleString() + "%" : "0%"; }
  165. function hourRange(h) { function p(x) { return (x < 10 ? "0" : "") + x; } return p(h) + ":00–" + p((h + 1) % 24) + ":00"; }
  166. function ago(iso) {
  167. var t = Date.parse(iso);
  168. if (isNaN(t)) return "";
  169. var s = Math.round((t - Date.now()) / 1000), a = Math.abs(s);
  170. var units = [["day", 86400], ["hour", 3600], ["minute", 60]];
  171. var rtf = typeof Intl !== "undefined" && Intl.RelativeTimeFormat ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
  172. for (var i = 0; i < units.length; i++) {
  173. if (a >= units[i][1]) {
  174. var n = Math.round(s / units[i][1]);
  175. return rtf ? rtf.format(n, units[i][0]) : Math.abs(n) + " " + units[i][0] + "(s) ago";
  176. }
  177. }
  178. return "just now";
  179. }
  180. // Everything worked out once, from the numbers pisg wrote. Older channels.json files have fewer fields;
  181. // whatever is missing simply leaves that piece out.
  182. function prepare(c) {
  183. var hours = list(c.hours, 24), total = 0, peak = 0, i;
  184. if (hours) for (i = 0; i < 24; i++) { total += hours[i]; if (hours[i] > hours[peak]) peak = i; }
  185. var lines = whole(c.lines), days = whole(c.days);
  186. return {
  187. c: c, name: String(c.name), file: c.file, lines: lines, nicks: whole(c.nicks), days: days,
  188. lpd: days ? lines / days : 0, words: whole(c.words), links: whole(c.links), questions: whole(c.questions),
  189. hours: hours, hoursTotal: total, peak: peak, recent: list(c.recent),
  190. top: Array.isArray(c.top) ? c.top.filter(function (t) { return t && typeof t.nick === "string"; }) : []
  191. };
  192. }
  193. function sum(rows, k) { return rows.reduce(function (a, r) { return a + r[k]; }, 0); }
  194. function fact(dt, dd, sub) {
  195. var f = el("div", "fact"), t = el("dt", null, dt), d = el("dd", "num", dd);
  196. if (sub) d.appendChild(el("small", null, sub));
  197. f.appendChild(t); f.appendChild(d);
  198. return f;
  199. }
  200. function heroBit(text) { var b = el("b", null, text); return b; }
  201. function renderOverview(rows, newest) {
  202. var lines = sum(rows, "lines"), days = rows.reduce(function (a, r) { return Math.max(a, r.days); }, 0);
  203. var hero = $("hero");
  204. hero.textContent = "";
  205. hero.appendChild(heroBit(fmt(lines) + " lines"));
  206. hero.appendChild(document.createTextNode(" in "));
  207. hero.appendChild(heroBit(fmt(rows.length) + (rows.length === 1 ? " channel" : " channels")));
  208. if (days) { hero.appendChild(document.createTextNode(", up to ")); hero.appendChild(heroBit(fmt(days) + " days")); hero.appendChild(document.createTextNode(" of history")); }
  209. var f = $("facts");
  210. f.textContent = "";
  211. var lpd = rows.reduce(function (a, r) { return a + r.lpd; }, 0);
  212. if (lpd) f.appendChild(fact("Lines per day, all channels", fmt(lpd)));
  213. var busiest = rows.slice().sort(function (a, b) { return b.lpd - a.lpd; })[0];
  214. if (busiest && busiest.lpd && rows.length > 1) f.appendChild(fact("Busiest channel", busiest.name, fmt(busiest.lpd) + " lines a day"));
  215. // Hour by hour across every channel that has an hour profile.
  216. var pool = [], t = 0, h;
  217. for (h = 0; h < 24; h++) pool.push(0);
  218. rows.forEach(function (r) { if (r.hours) r.hours.forEach(function (v, i) { pool[i] += v; t += v; }); });
  219. if (t) {
  220. var hi = 0, lo = 0;
  221. for (h = 1; h < 24; h++) { if (pool[h] > pool[hi]) hi = h; if (pool[h] < pool[lo]) lo = h; }
  222. f.appendChild(fact("Busiest hour, all channels", hourRange(hi), pct(pool[hi], t) + " of all lines"));
  223. f.appendChild(fact("Quietest hour", hourRange(lo), pct(pool[lo], t) + " of all lines"));
  224. }
  225. var words = sum(rows, "words"), links = sum(rows, "links"), q = sum(rows, "questions");
  226. if (words) f.appendChild(fact("Words", fmt(words), fmt1(words / Math.max(lines, 1)) + " per line"));
  227. if (links) f.appendChild(fact("Links shared", fmt(links)));
  228. if (q) f.appendChild(fact("Questions asked", fmt(q), pct(q, lines) + " of lines"));
  229. if (newest) f.appendChild(fact("Last update", ago(newest) || "just now"));
  230. $("overview").hidden = false;
  231. }
  232. function hoursChart(r) {
  233. var wrap = el("div"), box = el("div", "hours");
  234. if (!r.hours || !r.hoursTotal) return wrap;
  235. var max = r.hours[r.peak] || 1;
  236. r.hours.forEach(function (v, i) {
  237. var b = el("i", "h" + Math.floor(i / 6));
  238. b.style.height = Math.max(6, Math.round(100 * v / max)) + "%"; // the smallest hour still shows as a stub
  239. b.title = (i < 10 ? "0" : "") + i + ":00 – " + pct(v, r.hoursTotal) + " of this channel's lines";
  240. box.appendChild(b);
  241. });
  242. var ticks = el("div", "hticks");
  243. ["0", "6", "12", "18", "23"].forEach(function (x) { ticks.appendChild(el("span", null, x)); });
  244. wrap.appendChild(box); wrap.appendChild(ticks);
  245. return wrap;
  246. }
  247. function sparkline(r) {
  248. var NS = "http://www.w3.org/2000/svg", vals = r.recent;
  249. if (!vals || !vals.length) return el("span", "sub", "");
  250. var w = 150, h = 38, gap = 1.5, n = vals.length, bw = (w - gap * (n - 1)) / n;
  251. var max = vals.reduce(function (a, v) { return Math.max(a, v); }, 0) || 1;
  252. var svg = document.createElementNS(NS, "svg");
  253. svg.setAttribute("viewBox", "0 0 " + w + " " + h);
  254. svg.setAttribute("class", "spark");
  255. svg.setAttribute("preserveAspectRatio", "none");
  256. svg.setAttribute("role", "img");
  257. svg.setAttribute("aria-label", "Lines per day over the last " + n + " days of the log, busiest day " + fmt(max) + " lines");
  258. vals.forEach(function (v, i) {
  259. var bh = Math.max(1, Math.round(h * v / max)), rc = document.createElementNS(NS, "rect");
  260. rc.setAttribute("x", (i * (bw + gap)).toFixed(2)); rc.setAttribute("y", h - bh);
  261. rc.setAttribute("width", bw.toFixed(2)); rc.setAttribute("height", bh);
  262. var tt = document.createElementNS(NS, "title"); tt.textContent = fmt(v) + " lines";
  263. rc.appendChild(tt); svg.appendChild(rc);
  264. });
  265. var box = el("div");
  266. box.appendChild(svg);
  267. box.appendChild(el("span", "sub", "busiest day " + fmt(max)));
  268. return box;
  269. }
  270. function renderRows(rows) {
  271. var host = $("rows"), max = rows.reduce(function (a, r) { return Math.max(a, r.lines); }, 0) || 1, total = sum(rows, "lines");
  272. host.textContent = "";
  273. rows.forEach(function (r) {
  274. var row = el("div", "row");
  275. var name = el("div", "name"), a = el("a", null, r.name);
  276. a.href = r.file;
  277. name.appendChild(a);
  278. var meta = [];
  279. if (r.c.network) meta.push(String(r.c.network));
  280. var when = ago(r.c.updated);
  281. if (when) meta.push("updated " + when);
  282. if (meta.length) name.appendChild(el("span", "sub", meta.join(" · ")));
  283. var view = el("a", "btn", "View stats \u2192");
  284. view.href = r.file;
  285. view.setAttribute("aria-label", "View the statistics of " + r.name);
  286. name.appendChild(view);
  287. row.appendChild(name);
  288. var vol = el("div"), big = el("span", "big num", fmt(r.lines));
  289. vol.appendChild(big);
  290. vol.appendChild(document.createTextNode(" "));
  291. vol.appendChild(el("span", "sub", pct(r.lines, total) + " of all"));
  292. var track = el("div", "track"), fill = el("div", "fill");
  293. fill.style.width = Math.max(1, Math.round(100 * r.lines / max)) + "%";
  294. track.appendChild(fill); vol.appendChild(track);
  295. var bits = [];
  296. if (r.nicks) bits.push(fmt(r.nicks) + " nicks");
  297. if (r.lpd) bits.push(fmt(r.lpd) + " a day");
  298. if (r.days) bits.push(fmt(r.days) + " days");
  299. vol.appendChild(el("span", "sub", bits.join(" · ")));
  300. row.appendChild(vol);
  301. var busy = el("div");
  302. if (r.hoursTotal) {
  303. busy.appendChild(el("span", "what num", hourRange(r.peak)));
  304. busy.appendChild(el("span", "sub", pct(r.hours[r.peak], r.hoursTotal) + " of its lines"));
  305. }
  306. if (r.top.length) busy.appendChild(el("span", "sub", "top: " + r.top[0].nick + " (" + pct(whole(r.top[0].lines), r.lines) + ")"));
  307. row.appendChild(busy);
  308. var prof = hoursChart(r); prof.className = "profile";
  309. row.appendChild(prof);
  310. var spark = sparkline(r); spark.classList.add("trend");
  311. row.appendChild(spark);
  312. host.appendChild(row);
  313. });
  314. $("channels").hidden = false;
  315. }
  316. function renderPeople(rows) {
  317. var host = $("talkers"), shown = 0;
  318. host.textContent = "";
  319. rows.forEach(function (r) {
  320. if (!r.top.length) return;
  321. shown++;
  322. var box = el("div"), h3 = el("h3"), a = el("a", null, r.name);
  323. a.href = r.file; h3.appendChild(a); box.appendChild(h3);
  324. var ol = el("ol"), best = whole(r.top[0].lines) || 1;
  325. r.top.slice(0, 5).forEach(function (t) {
  326. var li = el("li"), tr = el("div", "track"), fl = el("div", "fill");
  327. fl.style.width = Math.max(2, Math.round(100 * whole(t.lines) / best)) + "%";
  328. tr.appendChild(fl);
  329. li.appendChild(el("b", null, t.nick)); li.appendChild(tr);
  330. li.appendChild(el("span", "num", fmt(whole(t.lines)) + " · " + pct(whole(t.lines), r.lines)));
  331. li.firstChild.title = t.nick;
  332. ol.appendChild(li);
  333. });
  334. box.appendChild(ol);
  335. host.appendChild(box);
  336. });
  337. $("people").hidden = !shown;
  338. }
  339. function start(list) {
  340. var chans = list.filter(function (c) { return c && typeof c.name === "string" && safeFile(c.file); });
  341. if (!chans.length) {
  342. say("<strong>No channels yet.</strong> Run <code>pisg</code> once: it writes <code>channels.json</code> next to its pages.");
  343. return;
  344. }
  345. // Old links looked like index.html#canada and opened that channel: keep them working.
  346. var want = norm((function () { var h = location.hash.replace(/^#/, ""); try { h = decodeURIComponent(h); } catch (e) {} return h; })());
  347. if (want) {
  348. for (var i = 0; i < chans.length; i++) if (norm(chans[i].name) === want) { location.replace(chans[i].file); return; }
  349. }
  350. var rows = chans.map(prepare).sort(function (a, b) { return b.lines - a.lines; });
  351. var newest = chans.map(function (c) { return c.updated; }).filter(Boolean).sort().pop();
  352. document.title = SITE_TITLE;
  353. clearSay();
  354. renderOverview(rows, newest);
  355. renderRows(rows);
  356. renderPeople(rows);
  357. }
  358. fetch(INDEX_URL, { cache: "no-store" })
  359. .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
  360. .then(function (d) {
  361. if (!d || !Array.isArray(d.channels)) throw new Error("unexpected format");
  362. start(d.channels);
  363. })
  364. .catch(function () {
  365. say("<strong>Could not load the channel list.</strong> Run <code>pisg</code> so it creates " +
  366. "<code>" + INDEX_URL + "</code>, and keep this page in the same folder as your channel pages.");
  367. });
  368. </script>
  369. </body>
  370. </html>