|
9 | 9 | var DAY_MS = 24 * 60 * 60 * 1000; |
10 | 10 | var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; |
11 | 11 |
|
12 | | - var root = document.getElementById("system-status-rows"); |
13 | | - var rangeLabel = document.getElementById("system-status-range"); |
14 | | - var prevBtn = document.querySelector('.status-nav-btn[data-dir="prev"]'); |
15 | | - var nextBtn = document.querySelector('.status-nav-btn[data-dir="next"]'); |
16 | | - if (!root) return; |
| 12 | + var SPINNER = |
| 13 | + '<div class="loading"><svg height="38" stroke="#aaa" width="38" xmlns="http://www.w3.org/2000/svg">' + |
| 14 | + '<g fill="none" fill-rule="evenodd"><g stroke-width="2" transform="translate(1 1)">' + |
| 15 | + '<circle cx="18" cy="18" r="18" stroke-opacity=".5"></circle>' + |
| 16 | + '<path d="M36 18c0-9.94-8.06-18-18-18"><animateTransform attributeName="transform" dur="1s" ' + |
| 17 | + 'from="0 18 18" repeatCount="indefinite" to="360 18 18" type="rotate"></animateTransform></path>' + |
| 18 | + "</g></g></svg></div>"; |
| 19 | + |
| 20 | + var SECTION_HTML = |
| 21 | + '<section id="system-status" class="system-status" aria-labelledby="system-status-heading">' + |
| 22 | + '<div class="f changed"><h2 id="system-status-heading">System status</h2>' + |
| 23 | + '<div class="status-nav">' + |
| 24 | + '<button type="button" class="status-nav-btn" data-dir="prev" aria-label="Previous period" disabled>‹</button>' + |
| 25 | + '<span class="status-range" id="system-status-range">Loading…</span>' + |
| 26 | + '<button type="button" class="status-nav-btn" data-dir="next" aria-label="Next period" disabled>›</button>' + |
| 27 | + "</div></div>" + |
| 28 | + '<div id="system-status-rows" class="status-rows">' + |
| 29 | + SPINNER + |
| 30 | + "</div></section>"; |
| 31 | + |
| 32 | + // Sapper hydrates over the SSR'd DOM and discards any element it |
| 33 | + // doesn't recognize as part of its own component tree — so this |
| 34 | + // section can't just be static markup in index.html, it has to be |
| 35 | + // built and (re-)inserted from script, and watched in case |
| 36 | + // hydration tears it out from under us. |
| 37 | + function findAnchor() { |
| 38 | + return document.querySelector(".f.changed"); |
| 39 | + } |
| 40 | + |
| 41 | + function buildSection() { |
| 42 | + var wrapper = document.createElement("div"); |
| 43 | + wrapper.innerHTML = SECTION_HTML; |
| 44 | + return wrapper.firstElementChild; |
| 45 | + } |
| 46 | + |
| 47 | + var refs = null; // { root, rangeLabel, prevBtn, nextBtn } |
| 48 | + |
| 49 | + function ensureSection() { |
| 50 | + var existing = document.getElementById("system-status"); |
| 51 | + if (existing) return existing; |
| 52 | + |
| 53 | + var anchor = findAnchor(); |
| 54 | + if (!anchor || !anchor.parentNode) return null; |
| 55 | + |
| 56 | + var section = buildSection(); |
| 57 | + anchor.parentNode.insertBefore(section, anchor); |
| 58 | + |
| 59 | + refs = { |
| 60 | + root: section.querySelector("#system-status-rows"), |
| 61 | + rangeLabel: section.querySelector("#system-status-range"), |
| 62 | + prevBtn: section.querySelector('.status-nav-btn[data-dir="prev"]'), |
| 63 | + nextBtn: section.querySelector('.status-nav-btn[data-dir="next"]'), |
| 64 | + }; |
| 65 | + refs.prevBtn.addEventListener("click", onPrev); |
| 66 | + refs.nextBtn.addEventListener("click", onNext); |
| 67 | + |
| 68 | + if (state.ready) render(); |
| 69 | + else if (state.error) showError(); |
| 70 | + |
| 71 | + return section; |
| 72 | + } |
| 73 | + |
| 74 | + function watchForRemoval() { |
| 75 | + var host = document.querySelector("main.container") || document.body; |
| 76 | + if (!host || !window.MutationObserver) return; |
| 77 | + new MutationObserver(function () { |
| 78 | + if (!document.getElementById("system-status")) ensureSection(); |
| 79 | + }).observe(host, { childList: true }); |
| 80 | + } |
17 | 81 |
|
18 | 82 | function toDateOnly(d) { |
19 | 83 | return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); |
|
130 | 194 | return row; |
131 | 195 | } |
132 | 196 |
|
133 | | - function init(sites, issuesBySlug, startBySlug) { |
| 197 | + var state = { |
| 198 | + ready: false, |
| 199 | + error: false, |
| 200 | + sites: null, |
| 201 | + issuesBySlug: {}, |
| 202 | + startBySlug: {}, |
| 203 | + windowEnd: toDateOnly(new Date()), |
| 204 | + }; |
| 205 | + |
| 206 | + function render() { |
| 207 | + if (!refs || !refs.root) return; |
134 | 208 | var today = toDateOnly(new Date()); |
135 | | - var windowEnd = today; |
136 | | - |
137 | | - function render() { |
138 | | - var windowStart = addDays(windowEnd, -(WINDOW_DAYS - 1)); |
139 | | - root.innerHTML = ""; |
140 | | - sites.forEach(function (site) { |
141 | | - var events = issuesBySlug[site.slug] || []; |
142 | | - var siteStart = startBySlug[site.slug] || null; |
143 | | - root.appendChild(renderRow(site, events, siteStart, windowStart, windowEnd)); |
144 | | - }); |
145 | | - rangeLabel.textContent = formatRange(windowStart, windowEnd); |
146 | | - |
147 | | - var earliestStart = sites.reduce(function (min, site) { |
148 | | - var s = startBySlug[site.slug]; |
149 | | - if (!s) return min; |
150 | | - return !min || s < min ? s : min; |
151 | | - }, null); |
152 | | - prevBtn.disabled = !!earliestStart && windowStart <= earliestStart; |
153 | | - nextBtn.disabled = windowEnd >= today; |
154 | | - } |
| 209 | + var windowStart = addDays(state.windowEnd, -(WINDOW_DAYS - 1)); |
155 | 210 |
|
156 | | - prevBtn.addEventListener("click", function () { |
157 | | - windowEnd = addDays(windowEnd, -WINDOW_DAYS); |
158 | | - render(); |
159 | | - }); |
160 | | - nextBtn.addEventListener("click", function () { |
161 | | - windowEnd = addDays(windowEnd, WINDOW_DAYS); |
162 | | - if (windowEnd > today) windowEnd = today; |
163 | | - render(); |
| 211 | + refs.root.innerHTML = ""; |
| 212 | + state.sites.forEach(function (site) { |
| 213 | + var events = state.issuesBySlug[site.slug] || []; |
| 214 | + var siteStart = state.startBySlug[site.slug] || null; |
| 215 | + refs.root.appendChild(renderRow(site, events, siteStart, windowStart, state.windowEnd)); |
164 | 216 | }); |
| 217 | + refs.rangeLabel.textContent = formatRange(windowStart, state.windowEnd); |
165 | 218 |
|
| 219 | + var earliestStart = state.sites.reduce(function (min, site) { |
| 220 | + var s = state.startBySlug[site.slug]; |
| 221 | + if (!s) return min; |
| 222 | + return !min || s < min ? s : min; |
| 223 | + }, null); |
| 224 | + refs.prevBtn.disabled = !!earliestStart && windowStart <= earliestStart; |
| 225 | + refs.nextBtn.disabled = state.windowEnd >= today; |
| 226 | + } |
| 227 | + |
| 228 | + function showError() { |
| 229 | + if (!refs || !refs.root) return; |
| 230 | + refs.root.innerHTML = ""; |
| 231 | + refs.root.appendChild(el("p", "status-error", "Status data is unavailable right now.")); |
| 232 | + refs.rangeLabel.textContent = ""; |
| 233 | + refs.prevBtn.disabled = true; |
| 234 | + refs.nextBtn.disabled = true; |
| 235 | + } |
| 236 | + |
| 237 | + function onPrev() { |
| 238 | + state.windowEnd = addDays(state.windowEnd, -WINDOW_DAYS); |
166 | 239 | render(); |
167 | 240 | } |
168 | 241 |
|
169 | | - fetchJson(RAW_BASE + "summary.json") |
170 | | - .then(function (sites) { |
171 | | - var startBySlug = {}; |
172 | | - var startFetches = sites.map(function (site) { |
173 | | - return fetch(RAW_BASE + site.slug + ".yml") |
174 | | - .then(function (res) { |
175 | | - return res.ok ? res.text() : ""; |
176 | | - }) |
177 | | - .then(function (text) { |
178 | | - var m = text.match(/startTime:\s*([^\n]+)/); |
179 | | - if (m) startBySlug[site.slug] = toDateOnly(new Date(m[1].trim())); |
180 | | - }) |
181 | | - .catch(function () {}); |
182 | | - }); |
| 242 | + function onNext() { |
| 243 | + var today = toDateOnly(new Date()); |
| 244 | + state.windowEnd = addDays(state.windowEnd, WINDOW_DAYS); |
| 245 | + if (state.windowEnd > today) state.windowEnd = today; |
| 246 | + render(); |
| 247 | + } |
183 | 248 |
|
184 | | - var issuesBySlug = {}; |
185 | | - var issuesFetch = fetchJson(ISSUES_URL) |
186 | | - .then(function (issues) { |
187 | | - var events = issues.map(classifyIssue).filter(Boolean); |
188 | | - // Single-monitor setup: attribute all events to every site. |
189 | | - sites.forEach(function (site) { |
190 | | - issuesBySlug[site.slug] = events; |
191 | | - }); |
192 | | - }) |
193 | | - .catch(function () { |
194 | | - // Rate-limited or offline — fall back to an all-green bar. |
195 | | - sites.forEach(function (site) { |
196 | | - issuesBySlug[site.slug] = []; |
197 | | - }); |
| 249 | + function loadData() { |
| 250 | + fetchJson(RAW_BASE + "summary.json") |
| 251 | + .then(function (sites) { |
| 252 | + state.sites = sites; |
| 253 | + |
| 254 | + var startFetches = sites.map(function (site) { |
| 255 | + return fetch(RAW_BASE + site.slug + ".yml") |
| 256 | + .then(function (res) { |
| 257 | + return res.ok ? res.text() : ""; |
| 258 | + }) |
| 259 | + .then(function (text) { |
| 260 | + var m = text.match(/startTime:\s*([^\n]+)/); |
| 261 | + if (m) state.startBySlug[site.slug] = toDateOnly(new Date(m[1].trim())); |
| 262 | + }) |
| 263 | + .catch(function () {}); |
198 | 264 | }); |
199 | 265 |
|
200 | | - return Promise.all(startFetches.concat([issuesFetch])).then(function () { |
201 | | - init(sites, issuesBySlug, startBySlug); |
| 266 | + var issuesFetch = fetchJson(ISSUES_URL) |
| 267 | + .then(function (issues) { |
| 268 | + var events = issues.map(classifyIssue).filter(Boolean); |
| 269 | + sites.forEach(function (site) { |
| 270 | + state.issuesBySlug[site.slug] = events; |
| 271 | + }); |
| 272 | + }) |
| 273 | + .catch(function () { |
| 274 | + // Rate-limited or offline — fall back to an all-green bar. |
| 275 | + sites.forEach(function (site) { |
| 276 | + state.issuesBySlug[site.slug] = []; |
| 277 | + }); |
| 278 | + }); |
| 279 | + |
| 280 | + return Promise.all(startFetches.concat([issuesFetch])); |
| 281 | + }) |
| 282 | + .then(function () { |
| 283 | + state.ready = true; |
| 284 | + render(); |
| 285 | + }) |
| 286 | + .catch(function () { |
| 287 | + state.error = true; |
| 288 | + showError(); |
202 | 289 | }); |
203 | | - }) |
204 | | - .catch(function () { |
205 | | - root.innerHTML = ""; |
206 | | - root.appendChild(el("p", "status-error", "Status data is unavailable right now.")); |
207 | | - rangeLabel.textContent = ""; |
208 | | - prevBtn.disabled = true; |
209 | | - nextBtn.disabled = true; |
210 | | - }); |
| 290 | + } |
| 291 | + |
| 292 | + ensureSection(); |
| 293 | + watchForRemoval(); |
| 294 | + loadData(); |
211 | 295 | })(); |
0 commit comments