ความคืบหน้างาน ไฟล์ต้นฉบับ

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();
})();