tracker/static/live.js
// Live updates: the server pushes an "update" event when data or git changes.
// We re-fetch the same server-rendered page and swap the sidebar menu and the
// main content, so rendering stays on the server.
(() => {
const live = document.getElementById("live");
const setState = (state, text) => {
if (!live) return;
live.dataset.state = state;
live.querySelector(".live-text").textContent = text;
};
let busy = false;
let again = false;
// A review form someone is filling in must not be replaced by a live update.
const editing = () => document.querySelector("#uc-editor") !== null || document.querySelector("#mock-editor") !== null ||
document.querySelector("#main form[data-dirty]") !== null ||
(document.activeElement && document.activeElement.closest && document.activeElement.closest("#main form") !== null);
document.addEventListener("input", (e) => { const f = e.target.closest && e.target.closest("#main form"); if (f) f.dataset.dirty = "1"; });
// Remember the reviewer's name in this browser (a per-viewer convenience).
const NAME_KEY = "tracker-reviewer";
function fillReviewer() {
let name = "";
try { name = localStorage.getItem(NAME_KEY) || ""; } catch {}
if (!name) return;
document.querySelectorAll('input[name="reviewer"]').forEach((el) => { if (!el.value) el.value = name; });
}
document.addEventListener("submit", (e) => {
const el = e.target.querySelector && e.target.querySelector('input[name="reviewer"]');
if (el && el.value.trim()) { try { localStorage.setItem(NAME_KEY, el.value.trim()); } catch {} }
});
fillReviewer();
async function refresh() {
if (busy) { again = true; return; }
busy = true;
try {
const res = await fetch(location.pathname + location.search, { headers: { "X-Live": "1" }, cache: "no-store" });
const html = await res.text();
const next = new DOMParser().parseFromString(html, "text/html");
swap(next, ".nav");
swap(next, "#loaded-at");
const main = document.getElementById("main");
const nextMain = next.getElementById("main");
if (editing()) {
// Keep the form; the menu badges above are already updated.
setState("open", "มีข้อมูลใหม่ · ฟอร์มและ diagram ยังคงอยู่");
return;
}
if (main && nextMain) {
const open = [...main.querySelectorAll("details[open]")].map(pathOf);
// Keep the scroll position of wide areas such as the timeline.
const scrolls = [...main.querySelectorAll("[data-keep-scroll][id]")].map((el) => [el.id, el.scrollLeft, el.scrollTop]);
const before = main.innerHTML;
main.innerHTML = nextMain.innerHTML;
scrolls.forEach(([id, l, t]) => { const el = document.getElementById(id); if (el) { el.scrollLeft = l; el.scrollTop = t; } });
document.dispatchEvent(new CustomEvent("tracker:updated"));
open.forEach((p) => { const d = byPath(main, p); if (d) d.open = true; });
fillReviewer();
if (before !== main.innerHTML) {
main.classList.remove("updated");
void main.offsetWidth;
main.classList.add("updated");
}
}
document.title = next.title;
setState("open", "อัปเดตแบบเรียลไทม์");
} catch {
setState("closed", "ดึงข้อมูลไม่สำเร็จ จะลองใหม่");
} finally {
busy = false;
if (again) { again = false; refresh(); }
}
}
function swap(next, selector) {
const cur = document.querySelector(selector);
const nxt = next.querySelector(selector);
if (cur && nxt) cur.replaceWith(nxt);
}
// Remember which <details> were open by their position in the document.
function pathOf(el) {
return [...document.querySelectorAll("#main details")].indexOf(el);
}
function byPath(root, i) {
return root.querySelectorAll("details")[i];
}
if (!("EventSource" in window)) {
setState("closed", "เบราว์เซอร์นี้ไม่รองรับการอัปเดตอัตโนมัติ");
return;
}
// Only a visible tab keeps the stream open. Browsers allow about 6
// connections to one site over HTTP/1.1, and every open stream holds one, so
// a few background tabs would otherwise make every new page wait.
let es = null;
let dropped = false;
function connect() {
if (es) return;
es = new EventSource("/events");
es.addEventListener("hello", () => {
setState("open", "อัปเดตแบบเรียลไทม์");
if (dropped) { dropped = false; refresh(); } // catch up on changes missed while offline or hidden
});
es.addEventListener("update", refresh);
es.onerror = () => { dropped = true; setState("closed", "ขาดการเชื่อมต่อ กำลังเชื่อมใหม่"); };
}
function disconnect() {
if (!es) return;
es.close();
es = null;
dropped = true;
}
document.addEventListener("visibilitychange", () => (document.hidden ? disconnect() : connect()));
window.addEventListener("pagehide", disconnect);
window.addEventListener("pageshow", () => { if (!document.hidden) connect(); });
if (!document.hidden) connect();
})();