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