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

tracker/static/timeline.js

// Timeline page: fold Milestone / Epic rows, remember what is folded, resize
// the label column, and scroll to "now". Rendering and positions come from the
// server (bar positions are % of the chart width).
(() => {
  const KEY = "tracker-timeline-collapsed";
  const WIDTH_KEY = "tracker-timeline-label-width";
  const MIN_LABEL = 200;
  const load = () => { try { return new Set(JSON.parse(localStorage.getItem(KEY) || "[]")); } catch { return new Set(); } };
  const save = (set) => { try { localStorage.setItem(KEY, JSON.stringify([...set])); } catch {} };

  // Hide every row below a folded row (children, grandchildren, …).
  function apply() {
    const tl = document.getElementById("timeline");
    if (!tl) return;
    let w = null;
    try { w = parseFloat(localStorage.getItem(WIDTH_KEY)); } catch {}
    if (w > 0) setLabelWidth(tl, w, false);
    const collapsed = load();
    const rows = [...tl.querySelectorAll(".tl-row")];
    const hiddenParents = new Set();
    rows.forEach((row) => {
      const parent = row.dataset.parent;
      const hide = parent && (collapsed.has(parent) || hiddenParents.has(parent));
      row.hidden = !!hide;
      if (hide || collapsed.has(row.dataset.id)) hiddenParents.add(row.dataset.id);
      const btn = row.querySelector("[data-tl-toggle]");
      if (btn) btn.setAttribute("aria-expanded", String(!collapsed.has(row.dataset.id)));
    });
  }

  // Width of the label column; never so wide that the chart disappears.
  function setLabelWidth(tl, px, remember) {
    const max = Math.max(MIN_LABEL, tl.clientWidth - 160);
    const w = Math.round(Math.min(max, Math.max(MIN_LABEL, px)));
    tl.style.setProperty("--tl-label", w + "px");
    const sep = tl.querySelector("[data-tl-split]");
    if (sep) {
      sep.setAttribute("aria-valuenow", String(w));
      sep.setAttribute("aria-valuemin", String(MIN_LABEL));
      sep.setAttribute("aria-valuemax", String(Math.round(max)));
    }
    if (remember) { try { localStorage.setItem(WIDTH_KEY, String(w)); } catch {} }
  }

  function labelWidth(tl) {
    const c = tl.querySelector(".tl-corner");
    return c ? c.offsetWidth : 0;
  }

  // Drag the border between labels and chart (header handle or any row's border).
  document.addEventListener("pointerdown", (e) => {
    if (e.button !== 0) return;
    const cell = e.target.closest("[data-tl-split], .tl-label, .tl-corner");
    const tl = cell && cell.closest("#timeline");
    if (!tl) return;
    const edge = cell.closest(".tl-label, .tl-corner").getBoundingClientRect().right;
    if (!e.target.hasAttribute("data-tl-split") && Math.abs(e.clientX - edge) > 5) return;
    e.preventDefault();
    const startX = e.clientX, startW = labelWidth(tl);
    tl.classList.add("is-resizing");
    const move = (ev) => setLabelWidth(tl, startW + ev.clientX - startX, false);
    const up = (ev) => {
      setLabelWidth(tl, startW + ev.clientX - startX, true);
      tl.classList.remove("is-resizing");
      document.removeEventListener("pointermove", move);
      document.removeEventListener("pointerup", up);
      document.removeEventListener("pointercancel", up);
    };
    document.addEventListener("pointermove", move);
    document.addEventListener("pointerup", up);
    document.addEventListener("pointercancel", up);
  });

  // Keyboard: arrows move the border, Home goes back to the default width.
  document.addEventListener("keydown", (e) => {
    const sep = e.target.closest && e.target.closest("[data-tl-split]");
    if (!sep) return;
    const tl = sep.closest("#timeline");
    const step = e.shiftKey ? 80 : 20;
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      setLabelWidth(tl, labelWidth(tl) + (e.key === "ArrowRight" ? step : -step), true);
    } else if (e.key === "Home") {
      e.preventDefault();
      tl.style.removeProperty("--tl-label");
      try { localStorage.removeItem(WIDTH_KEY); } catch {}
    }
  });

  function scrollToNow(smooth) {
    const tl = document.getElementById("timeline");
    if (!tl) return;
    const nowPct = parseFloat(tl.dataset.now);
    const axis = tl.querySelector(".tl-axis");
    if (!(nowPct >= 0) || !axis) return;
    const now = (nowPct / 100) * axis.offsetWidth;
    const labelW = labelWidth(tl);
    const left = Math.max(0, now - (tl.clientWidth - labelW) / 2);
    tl.scrollTo({ left, behavior: smooth ? "smooth" : "auto" });
  }

  document.addEventListener("click", (e) => {
    const t = e.target.closest("[data-tl-toggle], [data-tl-today], [data-tl-expand], [data-tl-collapse]");
    if (!t) return;
    if (t.dataset.tlToggle) {
      const set = load();
      set.has(t.dataset.tlToggle) ? set.delete(t.dataset.tlToggle) : set.add(t.dataset.tlToggle);
      save(set);
      apply();
    } else if (t.hasAttribute("data-tl-today")) {
      scrollToNow(true);
    } else if (t.hasAttribute("data-tl-expand")) {
      save(new Set());
      apply();
    } else if (t.hasAttribute("data-tl-collapse")) {
      const ids = [...document.querySelectorAll("#timeline .tl-epic")].map((r) => r.dataset.id);
      save(new Set(ids));
      apply();
    }
  });

  document.addEventListener("tracker:updated", apply);
  apply();
  scrollToNow(false);
})();