|
| 1 | +/* ========================================================================== |
| 2 | + * RCAC System Status / Outage Widget |
| 3 | + * -------------------------------------------------------------------------- |
| 4 | + * Floating status pill for the RCAC-Docs MkDocs Material site. |
| 5 | + * Data source: the Halcyon CMS JSON API on the main RCAC website |
| 6 | + * https://www.rcac.purdue.edu/api/news (articles) |
| 7 | + * https://www.rcac.purdue.edu/api/news/types (news types) |
| 8 | + * |
| 9 | + * No build step, no dependencies. Registered via `extra_javascript` |
| 10 | + * in mkdocs.yml. Compatible with Material's instant navigation. |
| 11 | + * |
| 12 | + * Behavior: |
| 13 | + * - By default the widget only appears on pages containing an element |
| 14 | + * with id="outage-widget-anchor" (the homepage). Set |
| 15 | + * CONFIG.homepageOnly = false to show it on every page. |
| 16 | + * - Green = no active or upcoming items |
| 17 | + * - Amber = maintenance/outage scheduled within `upcomingWindowDays` |
| 18 | + * - Red = outage/maintenance active right now |
| 19 | + * ========================================================================== */ |
| 20 | + |
| 21 | +(function () { |
| 22 | + "use strict"; |
| 23 | + |
| 24 | + var CONFIG = { |
| 25 | + apiBase: "https://www.rcac.purdue.edu/api", |
| 26 | + // Where "view all" links go: |
| 27 | + newsPage: "https://www.rcac.purdue.edu/news/outages-and-maintenance", |
| 28 | + // News type whose alias matches this pattern is treated as outage news: |
| 29 | + typeAliasPattern: /outage/i, |
| 30 | + homepageOnly: true, // require #outage-widget-anchor on the page |
| 31 | + upcomingWindowDays: 14, // how far ahead to surface scheduled work |
| 32 | + staleActiveDays: 7, // open-ended items older than this are ignored |
| 33 | + cacheMinutes: 5, // sessionStorage cache TTL |
| 34 | + refreshMinutes: 5, // background refresh while page is open |
| 35 | + maxItems: 5, // max entries listed in the panel |
| 36 | + timeZone: "America/Indiana/Indianapolis" |
| 37 | + }; |
| 38 | + |
| 39 | + var CACHE_KEY = "rcac-outage-widget-v1"; |
| 40 | + var TYPE_KEY = "rcac-outage-typeid-v1"; |
| 41 | + var OPEN_KEY = "rcac-outage-widget-open"; |
| 42 | + var refreshTimer = null; |
| 43 | + |
| 44 | + /* ------------------------------------------------------------------ * |
| 45 | + * Time handling: Halcyon returns naive timestamps in Eastern time, |
| 46 | + * e.g. "2026-06-09 10:00:00". Convert to a real Date in the user's |
| 47 | + * local clock by computing the Eastern UTC offset for that instant. |
| 48 | + * ------------------------------------------------------------------ */ |
| 49 | + function parseEastern(str) { |
| 50 | + if (!str) return null; |
| 51 | + var m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/); |
| 52 | + if (!m) return null; |
| 53 | + // Pretend the wall-clock time is UTC, then correct by the zone offset. |
| 54 | + var asUTC = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); |
| 55 | + var offset = easternOffsetMs(new Date(asUTC)); |
| 56 | + return new Date(asUTC - offset); |
| 57 | + } |
| 58 | + |
| 59 | + function easternOffsetMs(date) { |
| 60 | + try { |
| 61 | + var dtf = new Intl.DateTimeFormat("en-US", { |
| 62 | + timeZone: CONFIG.timeZone, |
| 63 | + hour12: false, |
| 64 | + year: "numeric", month: "2-digit", day: "2-digit", |
| 65 | + hour: "2-digit", minute: "2-digit", second: "2-digit" |
| 66 | + }); |
| 67 | + var parts = {}; |
| 68 | + dtf.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; }); |
| 69 | + var asIfUTC = Date.UTC( |
| 70 | + +parts.year, +parts.month - 1, +parts.day, |
| 71 | + (+parts.hour) % 24, +parts.minute, +parts.second |
| 72 | + ); |
| 73 | + return asIfUTC - date.getTime(); |
| 74 | + } catch (e) { |
| 75 | + return -5 * 3600 * 1000; // EST fallback |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /* ------------------------------------------------------------------ * |
| 80 | + * Data fetching |
| 81 | + * ------------------------------------------------------------------ */ |
| 82 | + function getCached() { |
| 83 | + try { |
| 84 | + var raw = sessionStorage.getItem(CACHE_KEY); |
| 85 | + if (!raw) return null; |
| 86 | + var obj = JSON.parse(raw); |
| 87 | + if (Date.now() - obj.t > CONFIG.cacheMinutes * 60 * 1000) return null; |
| 88 | + return obj.data; |
| 89 | + } catch (e) { return null; } |
| 90 | + } |
| 91 | + |
| 92 | + function setCached(data) { |
| 93 | + try { |
| 94 | + sessionStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), data: data })); |
| 95 | + } catch (e) { /* storage full or unavailable — ignore */ } |
| 96 | + } |
| 97 | + |
| 98 | + function fetchJSON(url) { |
| 99 | + return fetch(url, { headers: { Accept: "application/json" } }).then(function (r) { |
| 100 | + if (!r.ok) throw new Error("HTTP " + r.status + " for " + url); |
| 101 | + return r.json(); |
| 102 | + }); |
| 103 | + } |
| 104 | + |
| 105 | + function resolveOutageTypeId() { |
| 106 | + try { |
| 107 | + var cached = JSON.parse(localStorage.getItem(TYPE_KEY) || "null"); |
| 108 | + if (cached && Date.now() - cached.t < 24 * 3600 * 1000) { |
| 109 | + return Promise.resolve(cached.id); |
| 110 | + } |
| 111 | + } catch (e) { /* fall through to fetch */ } |
| 112 | + |
| 113 | + return fetchJSON(CONFIG.apiBase + "/news/types?limit=100").then(function (json) { |
| 114 | + var types = json.data || json; |
| 115 | + var match = (types || []).find(function (t) { |
| 116 | + return CONFIG.typeAliasPattern.test(t.alias || "") || |
| 117 | + CONFIG.typeAliasPattern.test(t.name || ""); |
| 118 | + }); |
| 119 | + if (!match) throw new Error("Outage news type not found"); |
| 120 | + try { |
| 121 | + localStorage.setItem(TYPE_KEY, JSON.stringify({ t: Date.now(), id: match.id })); |
| 122 | + } catch (e) { /* ignore */ } |
| 123 | + return match.id; |
| 124 | + }); |
| 125 | + } |
| 126 | + |
| 127 | + function fetchStatus() { |
| 128 | + var cached = getCached(); |
| 129 | + if (cached) return Promise.resolve(cached); |
| 130 | + |
| 131 | + return resolveOutageTypeId() |
| 132 | + .then(function (typeId) { |
| 133 | + var url = CONFIG.apiBase + "/news?type=" + typeId + |
| 134 | + "&limit=25&order=datetimecreated&order_dir=desc"; |
| 135 | + return fetchJSON(url); |
| 136 | + }) |
| 137 | + .then(function (json) { |
| 138 | + var data = classify(json.data || []); |
| 139 | + setCached(data); |
| 140 | + return data; |
| 141 | + }); |
| 142 | + } |
| 143 | + |
| 144 | + /* ------------------------------------------------------------------ * |
| 145 | + * Classification |
| 146 | + * ------------------------------------------------------------------ */ |
| 147 | + function classify(articles) { |
| 148 | + var now = new Date(); |
| 149 | + var horizon = new Date(now.getTime() + CONFIG.upcomingWindowDays * 86400000); |
| 150 | + var staleCutoff = new Date(now.getTime() - CONFIG.staleActiveDays * 86400000); |
| 151 | + var active = [], upcoming = []; |
| 152 | + |
| 153 | + articles.forEach(function (a) { |
| 154 | + if (!a || a.published === 0) return; |
| 155 | + var start = parseEastern(a.datetimenews); |
| 156 | + var end = parseEastern(a.datetimenewsend); |
| 157 | + if (!start) return; |
| 158 | + |
| 159 | + var item = { |
| 160 | + headline: a.headline || "Untitled", |
| 161 | + date: a.formatteddate || "", |
| 162 | + url: a.uri || CONFIG.newsPage, |
| 163 | + resources: (a.associations || []) |
| 164 | + .map(function (x) { return x && (x.name || x.associd); }) |
| 165 | + .filter(Boolean) |
| 166 | + }; |
| 167 | + |
| 168 | + if (start <= now) { |
| 169 | + if (end ? end >= now : start >= staleCutoff) active.push(item); |
| 170 | + } else if (start <= horizon) { |
| 171 | + upcoming.push(item); |
| 172 | + } |
| 173 | + }); |
| 174 | + |
| 175 | + return { |
| 176 | + active: active.slice(0, CONFIG.maxItems), |
| 177 | + upcoming: upcoming.slice(0, CONFIG.maxItems), |
| 178 | + checked: now.toISOString() |
| 179 | + }; |
| 180 | + } |
| 181 | + |
| 182 | + /* ------------------------------------------------------------------ * |
| 183 | + * Rendering |
| 184 | + * ------------------------------------------------------------------ */ |
| 185 | + function h(tag, attrs, children) { |
| 186 | + var el = document.createElement(tag); |
| 187 | + if (attrs) Object.keys(attrs).forEach(function (k) { |
| 188 | + if (k === "text") el.textContent = attrs[k]; |
| 189 | + else el.setAttribute(k, attrs[k]); |
| 190 | + }); |
| 191 | + (children || []).forEach(function (c) { el.appendChild(c); }); |
| 192 | + return el; |
| 193 | + } |
| 194 | + |
| 195 | + function removeWidget() { |
| 196 | + var existing = document.getElementById("rcac-outage-widget"); |
| 197 | + if (existing) existing.remove(); |
| 198 | + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } |
| 199 | + } |
| 200 | + |
| 201 | + function render(status) { |
| 202 | + removeWidget(); |
| 203 | + |
| 204 | + var level = status.active.length ? "error" |
| 205 | + : status.upcoming.length ? "warn" |
| 206 | + : "ok"; |
| 207 | + |
| 208 | + var label = level === "error" |
| 209 | + ? (status.active.length === 1 ? "1 active outage" |
| 210 | + : status.active.length + " active outages") |
| 211 | + : level === "warn" |
| 212 | + ? "Maintenance scheduled" |
| 213 | + : "All systems normal"; |
| 214 | + |
| 215 | + var root = h("div", { id: "rcac-outage-widget", "data-level": level }); |
| 216 | + |
| 217 | + /* --- panel --------------------------------------------------- */ |
| 218 | + var panel = h("div", { |
| 219 | + class: "rcac-ow-panel", |
| 220 | + role: "dialog", |
| 221 | + "aria-label": "RCAC system status", |
| 222 | + hidden: "" |
| 223 | + }); |
| 224 | + |
| 225 | + panel.appendChild(h("div", { class: "rcac-ow-head" }, [ |
| 226 | + h("span", { class: "rcac-ow-title", text: "System status" }), |
| 227 | + h("button", { |
| 228 | + class: "rcac-ow-close", |
| 229 | + type: "button", |
| 230 | + "aria-label": "Close status panel", |
| 231 | + text: "\u00d7" |
| 232 | + }) |
| 233 | + ])); |
| 234 | + |
| 235 | + var body = h("div", { class: "rcac-ow-body" }); |
| 236 | + |
| 237 | + function section(titleText, items, cls) { |
| 238 | + body.appendChild(h("p", { class: "rcac-ow-section " + cls, text: titleText })); |
| 239 | + var ul = h("ul", { class: "rcac-ow-list" }); |
| 240 | + items.forEach(function (it) { |
| 241 | + var a = h("a", { href: it.url, target: "_blank", rel: "noopener" }); |
| 242 | + a.appendChild(h("span", { class: "rcac-ow-item-title", text: it.headline })); |
| 243 | + var meta = it.date; |
| 244 | + if (it.resources.length) { |
| 245 | + meta += (meta ? " \u00b7 " : "") + it.resources.slice(0, 6).join(", "); |
| 246 | + } |
| 247 | + if (meta) a.appendChild(h("span", { class: "rcac-ow-item-meta", text: meta })); |
| 248 | + ul.appendChild(h("li", null, [a])); |
| 249 | + }); |
| 250 | + body.appendChild(ul); |
| 251 | + } |
| 252 | + |
| 253 | + if (status.active.length) section("Happening now", status.active, "is-error"); |
| 254 | + if (status.upcoming.length) { |
| 255 | + section("Upcoming (next " + CONFIG.upcomingWindowDays + " days)", |
| 256 | + status.upcoming, "is-warn"); |
| 257 | + } |
| 258 | + if (!status.active.length && !status.upcoming.length) { |
| 259 | + body.appendChild(h("p", { |
| 260 | + class: "rcac-ow-empty", |
| 261 | + text: "No outages reported and no maintenance scheduled in the next " + |
| 262 | + CONFIG.upcomingWindowDays + " days." |
| 263 | + })); |
| 264 | + } |
| 265 | + |
| 266 | + panel.appendChild(body); |
| 267 | + panel.appendChild(h("div", { class: "rcac-ow-foot" }, [ |
| 268 | + h("a", { |
| 269 | + href: CONFIG.newsPage, target: "_blank", rel: "noopener", |
| 270 | + text: "All outages & maintenance \u2192" |
| 271 | + }) |
| 272 | + ])); |
| 273 | + |
| 274 | + /* --- pill ----------------------------------------------------- */ |
| 275 | + var pill = h("button", { |
| 276 | + class: "rcac-ow-pill", |
| 277 | + type: "button", |
| 278 | + "aria-expanded": "false", |
| 279 | + "aria-label": "RCAC system status: " + label |
| 280 | + }, [ |
| 281 | + h("span", { class: "rcac-ow-dot", "aria-hidden": "true" }), |
| 282 | + h("span", { class: "rcac-ow-label", text: label }) |
| 283 | + ]); |
| 284 | + |
| 285 | + root.appendChild(panel); |
| 286 | + root.appendChild(pill); |
| 287 | + document.body.appendChild(root); |
| 288 | + |
| 289 | + function setOpen(open) { |
| 290 | + if (open) panel.removeAttribute("hidden"); |
| 291 | + else panel.setAttribute("hidden", ""); |
| 292 | + pill.setAttribute("aria-expanded", String(open)); |
| 293 | + try { sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (e) {} |
| 294 | + } |
| 295 | + |
| 296 | + pill.addEventListener("click", function () { |
| 297 | + setOpen(panel.hasAttribute("hidden")); |
| 298 | + }); |
| 299 | + panel.querySelector(".rcac-ow-close").addEventListener("click", function () { |
| 300 | + setOpen(false); |
| 301 | + }); |
| 302 | + document.addEventListener("keydown", function (ev) { |
| 303 | + if (ev.key === "Escape") setOpen(false); |
| 304 | + }); |
| 305 | + |
| 306 | + // Re-open if it was open before an instant-navigation reload, |
| 307 | + // or auto-open once per session when there is an active outage. |
| 308 | + var wasOpen = null; |
| 309 | + try { wasOpen = sessionStorage.getItem(OPEN_KEY); } catch (e) {} |
| 310 | + if (wasOpen === "1" || (level === "error" && wasOpen === null)) setOpen(true); |
| 311 | + } |
| 312 | + |
| 313 | + /* ------------------------------------------------------------------ * |
| 314 | + * Bootstrapping (works with and without navigation.instant) |
| 315 | + * ------------------------------------------------------------------ */ |
| 316 | + function shouldShowOnThisPage() { |
| 317 | + if (!CONFIG.homepageOnly) return true; |
| 318 | + return !!document.getElementById("outage-widget-anchor"); |
| 319 | + } |
| 320 | + |
| 321 | + function init() { |
| 322 | + if (!shouldShowOnThisPage()) { removeWidget(); return; } |
| 323 | + if (document.getElementById("rcac-outage-widget")) return; |
| 324 | + |
| 325 | + fetchStatus() |
| 326 | + .then(render) |
| 327 | + .catch(function (err) { |
| 328 | + // Fail quietly: a docs site must never break because the |
| 329 | + // status API is unreachable. |
| 330 | + console.warn("[outage-widget]", err); |
| 331 | + removeWidget(); |
| 332 | + }); |
| 333 | + |
| 334 | + refreshTimer = setInterval(function () { |
| 335 | + if (document.hidden) return; |
| 336 | + try { sessionStorage.removeItem(CACHE_KEY); } catch (e) {} |
| 337 | + fetchStatus().then(render).catch(function () {}); |
| 338 | + }, CONFIG.refreshMinutes * 60 * 1000); |
| 339 | + } |
| 340 | + |
| 341 | + if (window.document$ && typeof window.document$.subscribe === "function") { |
| 342 | + // Material instant navigation: fires on initial load and every page swap. |
| 343 | + window.document$.subscribe(init); |
| 344 | + } else if (document.readyState === "loading") { |
| 345 | + document.addEventListener("DOMContentLoaded", init); |
| 346 | + } else { |
| 347 | + init(); |
| 348 | + } |
| 349 | +})(); |
0 commit comments