Skip to content

Commit de21640

Browse files
author
purduercac-docs-bot
committed
Merge remote-tracking branch 'origin/main' into dev
2 parents 8b839a2 + 90f65eb commit de21640

3 files changed

Lines changed: 547 additions & 0 deletions

File tree

docs/assets/js/outage-widget.js

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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

Comments
 (0)