GitLab Board Improvements

Always show both issues and tasks on GitLab boards, display parent information (issue/epic) for each card, add a show/hide tasks toggle and standup helper UI, and show per-issue task completion progress based on child tasks fetched on demand.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitLab Board Improvements
// @namespace    https://iqnox.com
// @version      0.19
// @description  Always show both issues and tasks on GitLab boards, display parent information (issue/epic) for each card, add a show/hide tasks toggle and standup helper UI, and show per-issue task completion progress based on child tasks fetched on demand.
// @author       [email protected], ChatGPT
// @license      MIT
// @match        https://gitlab.com/*/-/boards*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(() => {
  "use strict";

  // Card selector constant for reuse across queries.
  const CARD_SELECTOR = ".board-card, .gl-issue-board-card, li[data-id]";
  const processedCards = new WeakSet();
  const TASK_HIDDEN_CLASS = "iqnox--task-hidden";
  const STANDUP_HIDDEN_CLASS = "iqnox--standup-hidden";
  const QUICK_FILTER_HIDDEN_CLASS = "iqnox--quick-filter-hidden";
  const WORK_ITEM_PARENT_QUERY = `
    query($id: WorkItemID!) {
      workItem(id: $id) {
        widgets {
          ... on WorkItemWidgetHierarchy {
            parent {
              id
              title
            }
          }
        }
      }
    }
  `.trim();
  const ISSUE_EPIC_QUERY = `
    query($fullPath: ID!, $iid: String!) {
      project(fullPath: $fullPath) {
        issue(iid: $iid) {
          epic {
            id
            title
          }
        }
      }
    }
  `.trim();
  const WORK_ITEM_PARENT_BY_NAMESPACE_QUERY = `
    query($fullPath: ID!, $iid: String!) {
      namespace(fullPath: $fullPath) {
        workItem(iid: $iid) {
          widgets {
            ... on WorkItemWidgetHierarchy {
              parent {
                id
                title
              }
            }
          }
        }
      }
    }
  `.trim();
  const WORK_ITEM_STALE_QUERY = `
    query($fullPath: ID!, $iid: String!) {
      namespace(fullPath: $fullPath) {
        workItem(iid: $iid) {
          updatedAt
        }
      }
    }
  `.trim();
  function ensureWorkItemTasksFeature() {
    if (!window.gon?.features) {
      console.warn("GitLab Board Improvements: gon.features not found");
    }
    window.gon.features.workItemTasksOnBoards = true;
    window.gon.features.workItemsClientSideBoards = true;
  }

  ensureWorkItemTasksFeature();

  // Debounce flag for task count UI updates.
  let scheduledTaskUI = false;
  /**
   * Schedule an update to the issue task count badges on the next animation frame.
   * This prevents thrashing the DOM when many child tasks finish loading at once.
   */
  function scheduleTaskCountUpdate() {
    if (scheduledTaskUI) return;
    scheduledTaskUI = true;
    requestAnimationFrame(() => {
      updateIssueTaskCountUI();
      scheduledTaskUI = false;
    });
  }

  function templateFragment(html) {
    const template = document.createElement("template");
    template.innerHTML = html.trim();
    return template.content.cloneNode(true);
  }

  function createInfoPill({ label, text, variant } = {}) {
    const pill = document.createElement("span");
    pill.className = `iqnox--info-pill${variant ? ` iqnox--${variant}-pill` : ""}`;
    const strong = document.createElement("strong");
    strong.textContent = `${label}:`;
    pill.appendChild(strong);
    pill.appendChild(document.createTextNode(` ${text || ""}`));
    return pill;
  }

  function createLocateParentButton(targetTitle) {
    const button = document.createElement("button");
    button.type = "button";
    button.textContent = "Locate";
    button.title = "Highlight parent card";
    button.className = "iqnox--locate-parent-btn";
    button.addEventListener("click", (event) => {
      event.stopPropagation();
      event.preventDefault();
      const parentCard = findCardByTitle(targetTitle);
      flashCardHighlight(parentCard);
    });
    return button;
  }

  function ensureCardInfoRow(card) {
    if (!card) return null;
    let info = card.querySelector(".iqnox--parent-info");
    if (info) return info;

    info = document.createElement("div");
    info.className = "iqnox--parent-info";
    const taskCount = card.querySelector(".iqnox--tasks-count");
    if (taskCount) {
      taskCount.insertAdjacentElement("beforebegin", info);
    } else {
      card.appendChild(info);
    }
    return info;
  }

  function findCardMetaRow(card) {
    if (!card) return null;

    const boardInfoItems = card.querySelector(".board-info-items");
    if (boardInfoItems) return boardInfoItems;

    const containerSelectors = [
      '[data-testid="board-card-metadata"]',
      ".board-card-metadata",
      ".board-card-footer",
      ".gl-issue-board-card-footer",
      ".gl-issue-card-details",
      ".issuable-info",
    ];
    for (const selector of containerSelectors) {
      const match = card.querySelector(selector);
      if (match) {
        const row = match.querySelector(".gl-flex-wrap, .gl-flex") || match;
        return row;
      }
    }

    const infoButtons = card.querySelectorAll(".board-card-info");
    if (infoButtons.length > 0) {
      const row = infoButtons[0].parentElement;
      if (row && infoButtons.length > 1) return row;
    }

    const title = card.querySelector(".board-card-title, [data-testid='board-card-title']");
    const titleContainer = title?.closest("div");
    if (titleContainer?.nextElementSibling) {
      return titleContainer.nextElementSibling;
    }

    return null;
  }

  /* ------------------------------------------------------------------------
   *  Board types filter (always show issues + tasks)
   * ---------------------------------------------------------------------- */

  function enforceIssueAndTaskFilter() {
    try {
      const url = new URL(window.location.href);
      const params = url.searchParams;
      const types = params.getAll("types[]");
      const hasIssue = types.includes("ISSUE");
      const hasTask = types.includes("TASK");
      if (!hasIssue || !hasTask) {
        params.delete("types[]");
        params.append("types[]", "ISSUE");
        params.append("types[]", "TASK");
        window.location.replace(url.toString());
      }
    } catch (err) {
      console.warn("Failed to enforce issue/task filter:", err);
    }
  }

  /* ------------------------------------------------------------------------
   *  Per-board "show tasks" state (localStorage)
   * ---------------------------------------------------------------------- */

  function getBoardId() {
    const match = window.location.pathname.match(/\/boards\/(\d+)/);
    return match ? match[1] : "default";
  }

  function getBoardKey() {
    return `gitlab-board-show-tasks-${getBoardId()}`;
  }

  function getBoardMineFilterKey() {
    return `gitlab-board-mine-filter-${getBoardId()}`;
  }

  function getBoardShowTasks() {
    const value = localStorage.getItem(getBoardKey());
    // default: false (tasks hidden)
    return value === "true";
  }

  function setBoardShowTasks(value) {
    localStorage.setItem(getBoardKey(), value ? "true" : "false");
  }

  function getBoardMineFilter() {
    return localStorage.getItem(getBoardMineFilterKey()) === "true";
  }

  function setBoardMineFilter(value) {
    localStorage.setItem(getBoardMineFilterKey(), value ? "true" : "false");
  }

  let showTasks = getBoardShowTasks();
  let mineFilterActive = getBoardMineFilter();
  let mineFilterLoading = mineFilterActive;

  function setWorkItemVisibility(card) {
    if (!card) return;
    card.classList.toggle(TASK_HIDDEN_CLASS, !showTasks);
  }

  function updateTaskVisibility() {
    document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
      const link = card.querySelector('a[href*="/-/"]');
      if (!link) return;
      if (getCardKind(card, link.href) === "task") {
        setWorkItemVisibility(card);
      }
    });
  }

  function normalizeIdentity(value) {
    return String(value || "")
      .replace(/^Avatar for\s+/i, "")
      .replace(/^@/, "")
      .trim()
      .toLowerCase();
  }

  function getAssigneeIdentityParts(value) {
    const raw = String(value || "").replace(/^Avatar for\s+/i, "").trim();
    if (!raw) {
      return { raw: "", normalized: "", key: "", isEmail: false };
    }

    const normalized = normalizeIdentity(raw);
    const atIndex = normalized.indexOf("@");
    const isEmail = atIndex > 0;
    return {
      raw,
      normalized,
      key: isEmail ? normalized.slice(0, atIndex) : normalized,
      isEmail,
    };
  }

  function normalizeAvatarIdentity(value) {
    const raw = String(value || "").trim();
    if (!raw) return "";
    try {
      const url = new URL(raw, window.location.origin);
      url.search = "";
      url.hash = "";
      return url.toString();
    } catch (_) {
      return raw.split("?")[0];
    }
  }

  function getAssigneeLabelScore(parts) {
    if (!parts?.raw) return 0;
    if (parts.isEmail) return 1;
    if (/\s/.test(parts.raw)) return 3;
    return 2;
  }

  function choosePreferredAssigneeLabel(currentLabel, nextParts) {
    if (!nextParts?.raw) return currentLabel || "";
    if (!currentLabel) return nextParts.raw;

    const currentParts = getAssigneeIdentityParts(currentLabel);
    return getAssigneeLabelScore(nextParts) > getAssigneeLabelScore(currentParts)
      ? nextParts.raw
      : currentLabel;
  }

  function getCurrentUserCandidates() {
    const candidates = new Set();
    [
      window.gon?.current_username,
      window.gon?.current_user_username,
      window.gon?.current_user_fullname,
      document.querySelector('meta[name="user-login"]')?.getAttribute("content"),
      document.querySelector('meta[name="user-name"]')?.getAttribute("content"),
    ]
      .map(normalizeIdentity)
      .filter(Boolean)
      .forEach((value) => candidates.add(value));
    return Array.from(candidates);
  }

  function getCardAssigneeImages(card) {
    return Array.from(card?.querySelectorAll("img[alt]") || []).filter(
      (img) =>
        !img.closest(
          ".iqnox--tasks-count, .iqnox--tasks-count__tooltip, .iqnox--tasks-count__avatar, .iqnox--child-avatar"
        )
    );
  }

  function cardMatchesMine(card) {
    const currentUser = getCurrentUserCandidates();
    if (currentUser.length === 0) return true;

    const cardAssignees = getCardAssigneeImages(card)
      .map((img) => normalizeIdentity(img.getAttribute("alt")))
      .filter(Boolean);

    return currentUser.some((candidate) =>
      cardAssignees.some(
        (assignee) =>
          assignee === candidate ||
          assignee.includes(candidate) ||
          candidate.includes(assignee)
      )
    );
  }

  function applyQuickFilters() {
    document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
      const hiddenByMine = mineFilterActive && !mineFilterLoading && !cardMatchesMine(card);
      card.classList.toggle(QUICK_FILTER_HIDDEN_CLASS, hiddenByMine);
    });
    const mineButton = document.querySelector(
      '[data-testid="iqnox-mine-filter-item"] .gl-toggle'
    );
    if (mineButton) {
      mineButton.classList.toggle("is-active", mineFilterActive);
      mineButton.setAttribute("aria-pressed", mineFilterActive ? "true" : "false");
      mineButton.setAttribute("aria-checked", mineFilterActive ? "true" : "false");
    }
  }

  function showBoardLoadingIndicator(text) {
    let indicator = document.querySelector(".iqnox--board-loading");
    if (!indicator) {
      indicator = document.createElement("div");
      indicator.className = "iqnox--board-loading";
      document.body.appendChild(indicator);
    }
    indicator.textContent = text || "Loading board…";
    indicator.hidden = false;
  }

  function hideBoardLoadingIndicator() {
    const indicator = document.querySelector(".iqnox--board-loading");
    if (indicator) indicator.hidden = true;
  }

  async function toggleMineFilter(nextValue) {
    mineFilterActive = !!nextValue;
    setBoardMineFilter(mineFilterActive);

    if (!mineFilterActive) {
      mineFilterLoading = false;
      hideBoardLoadingIndicator();
      applyQuickFilters();
      return;
    }

    mineFilterLoading = true;
    applyQuickFilters();
    showBoardLoadingIndicator("Loading full board for Mine filter…");
    try {
      await loadAllBoardColumns();
    } finally {
      mineFilterLoading = false;
      hideBoardLoadingIndicator();
      applyQuickFilters();
    }
  }

  /* ------------------------------------------------------------------------
   *  Helper: parse item type / id / namespace from card link
   * ---------------------------------------------------------------------- */

  function parseItemFromUrl(href) {
    try {
      const url = new URL(href);
      const segments = url.pathname.split("/").filter(Boolean);
      const dashIndex = segments.indexOf("-");
      if (dashIndex !== -1 && segments.length > dashIndex + 2) {
        const typeSegment = segments[dashIndex + 1];
        const idSegment = segments[dashIndex + 2];
        const namespaceSegments = segments.slice(0, dashIndex);
        return {
          namespace: (() => {
            const parts = [...namespaceSegments];
            if (parts[0] === "groups") parts.shift();
            return parts.join("/");
          })(),
          type: typeSegment,
          id: idSegment,
        };
      }
    } catch (_) {
      // ignore
    }
    return null;
  }

  function getCardKind(card, href) {
    const typeBadge = Array.from(card?.querySelectorAll("button, span, a") || [])
      .map((el) => el.textContent?.trim())
      .find((text) => text === "Issue" || text === "Task");
    if (typeBadge === "Issue") return "issue";
    if (typeBadge === "Task") return "task";

    const gid = card?.getAttribute("data-item-id") || "";
    if (gid.includes("/Issue/")) return "issue";
    if (gid.includes("/WorkItem/")) {
      const boardButton = card.querySelector('[data-testid="board-card-button"]');
      const ariaLabel = boardButton?.getAttribute("aria-label") || "";
      if (/^Issue number\b/i.test(ariaLabel)) return "issue";
      if (/^Task number\b/i.test(ariaLabel)) return "task";

      return "task";
    }

    const parsed = href ? parseItemFromUrl(href) : null;
    if (parsed?.type === "issues") return "issue";
    if (parsed?.type === "work_items") return "task";
    return null;
  }

  /* ------------------------------------------------------------------------
   *  GraphQL helpers (parents for tasks/issues, children for issues)
   * ---------------------------------------------------------------------- */

  async function fetchParent(globalId) {
    try {
      const response = await fetch("/api/graphql", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query: WORK_ITEM_PARENT_QUERY, variables: { id: globalId } }),
      });
      const json = await response.json();
      const widgets = json?.data?.workItem?.widgets;
      if (Array.isArray(widgets)) {
        for (const widget of widgets) {
          if (widget && widget.parent) {
            return widget.parent;
          }
        }
      }
    } catch (err) {
      console.error("Error fetching parent for", globalId, err);
    }
    return null;
  }

  async function fetchIssueEpic(fullPath, iid) {
    try {
      const response = await fetch("/api/graphql", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query: ISSUE_EPIC_QUERY,
          variables: { fullPath, iid: String(iid) },
        }),
      });
      const json = await response.json();
      return json?.data?.project?.issue?.epic || null;
    } catch (err) {
      console.error("Error fetching epic for issue", fullPath, iid, err);
      return null;
    }
  }

  // Tasks: parent via namespace(workItem(iid))
  async function fetchWorkItemParentByNamespace(fullPath, iid) {
    try {
      const response = await fetch("/api/graphql", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query: WORK_ITEM_PARENT_BY_NAMESPACE_QUERY,
          variables: { fullPath, iid: String(iid) },
        }),
      });
      const json = await response.json();
      const widgets = json?.data?.namespace?.workItem?.widgets;
      if (Array.isArray(widgets)) {
        for (const widget of widgets) {
          if (widget && widget.parent) {
            return widget.parent;
          }
        }
      }
    } catch (err) {
      console.error("Error fetching parent for work item", fullPath, iid, err);
    }
    return null;
  }

  async function buildParentChainForTask(fullPath, iid) {
    const chain = [];
    const immediate = await fetchWorkItemParentByNamespace(fullPath, iid);
    if (!immediate) return chain;
    chain.push(immediate);

    // Fetch grandparent epic via global WorkItem ID of the parent
    const globalId = immediate.id;
    const grand = await fetchParent(globalId);
    if (grand) chain.push(grand);
    return chain;
  }

  const staleInfoCache = {}; // key: `${fullPath}::${iid}` -> { updatedAt, staleDays, tone }

  function getStaleTone(staleDays) {
    if (staleDays >= 14) return "stale";
    if (staleDays >= 7) return "aging";
    return "fresh";
  }

  function formatStaleLabel(staleDays) {
    return `${staleDays}d`;
  }

  function formatDateTime(value) {
    if (!value) return "";
    try {
      return new Date(value).toLocaleString();
    } catch (_) {
      return value;
    }
  }

  async function fetchWorkItemStaleInfo(fullPath, iid) {
    const key = issueKey(fullPath, iid);
    if (staleInfoCache[key]) return staleInfoCache[key];

    try {
      const response = await fetch("/api/graphql", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query: WORK_ITEM_STALE_QUERY,
          variables: { fullPath, iid: String(iid) },
        }),
      });
      const json = await response.json();
      const updatedAt = json?.data?.namespace?.workItem?.updatedAt || "";
      if (!updatedAt) {
        staleInfoCache[key] = null;
        return null;
      }

      const ageMs = Date.now() - new Date(updatedAt).getTime();
      const staleDays = Math.max(0, Math.floor(ageMs / 86400000));
      const staleInfo = {
        updatedAt,
        staleDays,
        tone: getStaleTone(staleDays),
      };
      staleInfoCache[key] = staleInfo;
      return staleInfo;
    } catch (err) {
      console.error("Error fetching stale info for work item", fullPath, iid, err);
      staleInfoCache[key] = null;
      return null;
    }
  }

  function renderStaleBadge(card, staleInfo) {
    if (!card) return;

    let badge = card.querySelector(".iqnox--stale-badge");
    if (!staleInfo) {
      badge?.remove();
      return;
    }

    if (!badge) {
      badge = document.createElement("button");
      badge.type = "button";
      badge.className =
        "iqnox--stale-badge gl-flex gl-items-center gl-gap-2 gl-isolate gl-align-bottom board-card-info gl-text-sm gl-text-subtle !gl-cursor-help gl-bg-transparent gl-border-0 gl-p-0 focus-visible:gl-focus-inset";
      badge.innerHTML = `
        <span class="iqnox--stale-badge-dot" aria-hidden="true"></span>
        <span class="iqnox--stale-badge-text gl-truncate-component gl-min-w-0 board-card-info-text">
          <span class="gl-truncate-end"></span>
        </span>
      `;
    }

    const nativeMetaRow = findCardMetaRow(card);
    if (nativeMetaRow && badge.parentElement !== nativeMetaRow) {
      nativeMetaRow.appendChild(badge);
    } else if (!nativeMetaRow) {
      const info = ensureCardInfoRow(card);
      if (info && badge.parentElement !== info) {
        info.appendChild(badge);
      }
    }

    const text = badge.querySelector(".gl-truncate-end");
    if (text) {
      text.textContent = `Upd ${formatStaleLabel(staleInfo.staleDays)}`;
    } else {
      badge.textContent = `Upd ${formatStaleLabel(staleInfo.staleDays)}`;
    }
    badge.dataset.state = staleInfo.tone;
    badge.setAttribute(
      "aria-label",
      `Updated ${formatStaleLabel(staleInfo.staleDays)} ago`
    );
    badge.title =
      `Last updated ${formatDateTime(staleInfo.updatedAt)}\n` +
      `Shows how many days ago this card was updated.`;
  }

  /* ------------------------------------------------------------------------
   *  Issue children cache & progress rendering (fetch on demand)
   * ---------------------------------------------------------------------- */

  const issueChildrenCache = {}; // key: `${fullPath}::${iid}` -> array of children { id, title, state }
  const ISSUE_CHILDREN_QUERY = `
    query($fullPath: ID!, $iid: String!) {
      namespace(fullPath: $fullPath) {
        workItem(iid: $iid) {
          widgets {
            ... on WorkItemWidgetHierarchy {
              children(first: 100) {
                nodes {
                  id
                  iid
                  webUrl
                  title
                  state
                  widgets {
                    ... on WorkItemWidgetAssignees {
                      assignees {
                        nodes {
                          username
                          avatarUrl
                        }
                      }
                    }
                    ... on WorkItemWidgetStatus {
                      status {
                        name
                        color
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  `.trim();

  function issueKey(fullPath, iid) {
    return `${fullPath}::${iid}`;
  }

  async function fetchIssueChildren(fullPath, iid) {
    const key = issueKey(fullPath, iid);
    if (issueChildrenCache[key]) return issueChildrenCache[key];

    const query = ISSUE_CHILDREN_QUERY;

    try {
      const response = await fetch("/api/graphql", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query,
          variables: { fullPath, iid: String(iid) },
        }),
      });
      const json = await response.json();
      const widgets = json?.data?.namespace?.workItem?.widgets || [];
      let children = [];
      for (const widget of widgets) {
        if (widget?.children?.nodes) {
          children = widget.children.nodes.map((node) => {
            const assignees = [];
            let statusName = "";
            let statusColor = "";
            (node.widgets || []).forEach((nested) => {
              if (nested?.assignees?.nodes) {
                nested.assignees.nodes.forEach((person) => {
                  const username = person?.username;
                  const avatarUrl = person?.avatarUrl;
                  if (username && !assignees.some((a) => a.username === username)) {
                    assignees.push({ username, avatarUrl });
                  }
                });
              }
              const name = nested?.status?.name;
              if (name) statusName = name;
              const color = nested?.status?.color;
              if (color) statusColor = color;
            });
            return {
              id: node.id,
              iid: node.iid,
              webUrl: node.webUrl,
              title: node.title,
              state: node.state,
              statusName,
              statusColor,
              assignees,
            };
          });
          break;
        }
      }
      issueChildrenCache[key] = children;
      // Defer updating task counts to avoid spamming the DOM.
      scheduleTaskCountUpdate();
      return children;
    } catch (err) {
      console.error("Error fetching children for issue", fullPath, iid, err);
      issueChildrenCache[key] = [];
      // Defer updating task counts to avoid spamming the DOM.
      scheduleTaskCountUpdate();
      return [];
    }
  }

  function collectChildAssignees(children) {
    const seen = new Set();
    const assignees = [];

    (children || []).forEach((child) => {
      (child.assignees || []).forEach((assignee) => {
        const username = assignee?.username;
        if (!username || seen.has(username)) return;
        seen.add(username);
        assignees.push({
          username,
          avatarUrl: assignee.avatarUrl || "",
        });
      });
    });

    return assignees;
  }

  function createChildRowElement(child, className) {
    const href = child?.webUrl || "";
    const item = document.createElement(href ? "a" : "div");
    item.className = className;
    if (href) {
      item.href = href;
      item.target = "_blank";
      item.rel = "noopener noreferrer";
      item.addEventListener("click", (event) => {
        event.stopPropagation();
      });
    }

    const content = document.createElement("span");
    content.className = "iqnox--child-content";

    const name = document.createElement("span");
    name.className = "iqnox--child-name";
    name.textContent = child?.title || "";

    const statusBadge = document.createElement("span");
    statusBadge.className = "iqnox--child-status-badge";
    statusBadge.style.setProperty(
      "--iqnox-status-color",
      child?.statusColor || "#0f172a"
    );

    const statusText = document.createElement("span");
    statusText.className = "iqnox--child-status-text";
    statusText.textContent = child?.statusName || "Status unknown";
    statusBadge.appendChild(statusText);

    content.appendChild(name);
    content.appendChild(statusBadge);

    const avatar = document.createElement("span");
    avatar.className = "iqnox--child-avatar";
    const assigneeLabel =
      child?.assignees?.length > 0
        ? child.assignees.map((assignee) => assignee.username).join(", ")
        : "Unassigned";
    avatar.title = assigneeLabel;

    const avatarSrc = child?.assignees?.[0]?.avatarUrl || "";
    if (avatarSrc) {
      const img = document.createElement("img");
      img.src = avatarSrc;
      img.alt = assigneeLabel;
      avatar.appendChild(img);
    }

    item.appendChild(content);
    item.appendChild(avatar);
    return item;
  }

  function renderTaskBadgeContent(badge, children, completed, total, pct) {
    badge.innerHTML = "";

    const header = document.createElement("div");
    header.className = "iqnox--tasks-count__header";

    const title = document.createElement("span");
    title.textContent = "Tasks";
    const summary = document.createElement("span");
    summary.textContent = `${completed}/${total} (${pct}%)`;
    header.appendChild(title);
    header.appendChild(summary);

    const progress = document.createElement("div");
    progress.className = "iqnox--tasks-count__progress";
    const progressBar = document.createElement("div");
    progressBar.className = "iqnox--tasks-count__progress-bar";
    progressBar.style.setProperty("width", `${pct}%`);
    progress.appendChild(progressBar);

    const assignees = collectChildAssignees(children);
    const avatarRow = document.createElement("div");
    avatarRow.className = "iqnox--tasks-count__avatars";
    assignees.slice(0, 4).forEach((assignee) => {
      const avatar = document.createElement("span");
      avatar.className = "iqnox--tasks-count__avatar";
      avatar.title = assignee.username;
      if (assignee.avatarUrl) {
        const img = document.createElement("img");
        img.src = assignee.avatarUrl;
        img.alt = assignee.username;
        avatar.appendChild(img);
      } else {
        avatar.textContent = assignee.username.slice(0, 1).toUpperCase();
      }
      avatarRow.appendChild(avatar);
    });
    if (assignees.length > 4) {
      const more = document.createElement("span");
      more.className = "iqnox--tasks-count__avatar-more";
      more.textContent = `+${assignees.length - 4}`;
      avatarRow.appendChild(more);
    }

    const tooltip = document.createElement("div");
    tooltip.className = "iqnox--tasks-count__tooltip";

    const tooltipHeader = document.createElement("div");
    tooltipHeader.className = "iqnox--tasks-count__tooltip-header";
    tooltipHeader.textContent = `${total} child task${total === 1 ? "" : "s"}`;
    tooltip.appendChild(tooltipHeader);

    children.slice(0, 6).forEach((child) => {
      tooltip.appendChild(createChildRowElement(child, "iqnox--tasks-count__tooltip-row"));
    });
    if (children.length > 6) {
      const more = document.createElement("div");
      more.className = "iqnox--tasks-count__tooltip-more";
      more.textContent = `+${children.length - 6} more`;
      tooltip.appendChild(more);
    }

    badge.appendChild(header);
    badge.appendChild(progress);
    if (assignees.length > 0) {
      badge.appendChild(avatarRow);
    }
    badge.appendChild(tooltip);
  }

  function updateIssueTaskCountUI() {
    const cards = document.querySelectorAll(CARD_SELECTOR);

    cards.forEach((card) => {
      const link = card.querySelector('a[href*="/-/"]');
      if (!link) return;
      const parsed = parseItemFromUrl(link.href);
      if (!parsed || getCardKind(card, link.href) !== "issue") return;

      const key = issueKey(parsed.namespace, parsed.id);
      const children = issueChildrenCache[key];
      let badge = card.querySelector(".iqnox--tasks-count");

      if (!children || children.length === 0) {
        if (badge) badge.remove();
        return;
      }

      const total = children.length;
      const completed = children.filter((c) => c.state === "CLOSED").length;
      const pct = Math.round((completed / total) * 100);
      const isDone = completed === total;

      if (!badge) {
        badge = document.createElement("div");
        badge.className = "iqnox--tasks-count";
        card.appendChild(badge);
      }

      renderTaskBadgeContent(badge, children, completed, total, pct);

      badge.dataset.state = isDone ? "done" : "pending";
      badge.title = `Show ${total} child task${total === 1 ? "" : "s"}`;

    });
  }

  /* ------------------------------------------------------------------------
   *  Card processing: parents/epics + children progress
   * ---------------------------------------------------------------------- */

  function processCard(card) {
    if (!card) return;
    // Avoid duplicate processing of the same card across mutation observer events
    if (processedCards.has(card)) return;
    processedCards.add(card);
    if (card.dataset.parentInjected) return;

    const link = card.querySelector('a[href*="/-/"]');
    if (!link) return;
    const parsed = parseItemFromUrl(link.href);
    if (!parsed || !parsed.id) return;
    const cardKind = getCardKind(card, link.href);
    if (!cardKind) {
      card.dataset.parentInjected = "done";
      return;
    }

    card.dataset.parentInjected = "pending";
    const stalePromise = fetchWorkItemStaleInfo(parsed.namespace, parsed.id).then((staleInfo) => {
      renderStaleBadge(card, staleInfo);
    });

    if (cardKind === "task") {
      // Task / work item
      setWorkItemVisibility(card);

      Promise.all([buildParentChainForTask(parsed.namespace, parsed.id), stalePromise])
        .then(([chain]) => {
          if (!Array.isArray(chain) || chain.length === 0) return;
          const info = ensureCardInfoRow(card);

          if (chain[0]) {
            const parentSpan = createInfoPill({
              label: "Parent",
              text: chain[0].title,
              variant: "parent",
            });
            parentSpan.appendChild(createLocateParentButton(chain[0].title));
            info.appendChild(parentSpan);
          }

          if (chain[1]) {
            const epicSpan = createInfoPill({
              label: "Epic",
              text: chain[1].title,
              variant: "epic",
            });
            info.appendChild(epicSpan);
          }
        })
        .catch((err) => {
          console.error("Error processing task card", parsed.namespace, parsed.id, err);
        })
        .finally(() => {
          card.dataset.parentInjected = "done";
          renderStaleBadge(card, staleInfoCache[issueKey(parsed.namespace, parsed.id)]);
          applyQuickFilters();
        });
    } else if (cardKind === "issue") {
      // Issues: epic + children
      Promise.all([
        fetchIssueEpic(parsed.namespace, parsed.id),
        fetchIssueChildren(parsed.namespace, parsed.id),
        stalePromise,
      ])
        .then(([epic]) => {
          if (!epic) return;
          const info = ensureCardInfoRow(card);

          const epicSpan = createInfoPill({
            label: "Epic",
            text: epic.title,
            variant: "epic",
          });
          info.appendChild(epicSpan);
        })
        .catch((err) => {
          console.error("Error processing issue card", parsed.namespace, parsed.id, err);
        })
        .finally(() => {
          card.dataset.parentInjected = "done";
          // Defer badge updates rather than applying immediately.
          scheduleTaskCountUpdate();
          renderStaleBadge(card, staleInfoCache[issueKey(parsed.namespace, parsed.id)]);
          applyQuickFilters();
        });
    } else {
      card.dataset.parentInjected = "done";
      applyQuickFilters();
    }
  }

  function scanBoard() {
    const cards = document.querySelectorAll(CARD_SELECTOR);
    cards.forEach((card) => processCard(card));
    // Batch update task badges.
    scheduleTaskCountUpdate();
    applyQuickFilters();
  }

  /* ------------------------------------------------------------------------
   *  Mutation observer (cards)
   * ---------------------------------------------------------------------- */

  function setupMutationObserver() {
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          if (!(node instanceof HTMLElement)) return;

          if (
            node.matches &&
            node.matches(CARD_SELECTOR)
          ) {
            processCard(node);
          } else {
            const cards = node.querySelectorAll?.(CARD_SELECTOR);
            cards?.forEach((card) => processCard(card));
          }
        });
      });
      applyQuickFilters();
    });

    const observeTarget = document.body || document.documentElement || document;
    observer.observe(observeTarget, { childList: true, subtree: true });
    scanBoard();
  }

  /* ------------------------------------------------------------------------
   *  Dropdown: Show tasks toggle + standup item
   * ---------------------------------------------------------------------- */

  function insertShowTasksToggle() {
    try {
      const lists = document.querySelectorAll(".gl-new-dropdown-contents");
      lists.forEach((ul) => {
        const showLabelsItem = ul.querySelector(
          '[data-testid="show-labels-toggle-item"]'
        );
        if (!showLabelsItem) return;
        insertOrUpdateToggleItem({
          ul,
          afterItem: showLabelsItem,
          templateItem: showLabelsItem,
          testId: "show-tasks-toggle-item",
          label: "Show tasks",
          isChecked: showTasks,
          listenerKey: "tasksListenerAttached",
          onToggle: (nextValue) => {
            showTasks = nextValue;
            setBoardShowTasks(nextValue);
            updateTaskVisibility();
          },
        });
        insertAdditionalDropdownItems(ul);
      });
    } catch (err) {
      console.warn("Error inserting show tasks toggle:", err);
    }
  }

  function insertOrUpdateToggleItem({
    ul,
    afterItem,
    templateItem,
    testId,
    label,
    isChecked,
    listenerKey,
    onToggle,
  }) {
    let item = ul.querySelector(`[data-testid="${testId}"]`);
    if (!item) {
      item = templateItem.cloneNode(true);
      item.setAttribute("data-testid", testId);
      afterItem.parentNode.insertBefore(item, afterItem.nextSibling);
    }

    const labelSpan = item.querySelector(".gl-toggle-label");
    if (labelSpan) {
      labelSpan.textContent = label;
      labelSpan.id = `${testId}-label`;
    }

    const toggleButton = item.querySelector(".gl-toggle");
    if (!toggleButton) return;

    const useEl = toggleButton.querySelector("use");
    let iconBase = "";
    if (useEl) {
      const href = useEl.getAttribute("href");
      const idx = href ? href.indexOf("#") : -1;
      if (idx > 0) iconBase = href.substring(0, idx);
    }

    const onIcon = "check-xs";
    const offIcon = "close-xs";
    const applyState = (state) => {
      toggleButton.setAttribute("aria-checked", state ? "true" : "false");
      toggleButton.classList.toggle("is-checked", state);
      toggleButton.classList.toggle("is-active", state);
      if (labelSpan) {
        toggleButton.setAttribute("aria-labelledby", labelSpan.id);
      }
      const iconName = state ? onIcon : offIcon;
      if (useEl && iconBase) {
        useEl.setAttribute("href", `${iconBase}#${iconName}`);
      }
    };

    applyState(isChecked);

    if (!toggleButton.dataset[listenerKey]) {
      toggleButton.addEventListener("click", (event) => {
        event.stopPropagation();
        const nextValue = toggleButton.getAttribute("aria-checked") !== "true";
        applyState(nextValue);
        onToggle(nextValue);
      });
      toggleButton.dataset[listenerKey] = "true";
    }
  }

  function insertAdditionalDropdownItems(ul) {
    const showTasksItem = ul.querySelector('[data-testid="show-tasks-toggle-item"]');
    const showLabelsItem = ul.querySelector('[data-testid="show-labels-toggle-item"]');
    const anchorItem = showTasksItem || showLabelsItem;
    if (anchorItem) {
      insertOrUpdateToggleItem({
        ul,
        afterItem: anchorItem,
        templateItem: showLabelsItem || showTasksItem,
        testId: "iqnox-mine-filter-item",
        label: "Mine only",
        isChecked: mineFilterActive,
        listenerKey: "mineListenerAttached",
        onToggle: (nextValue) => {
          toggleMineFilter(nextValue);
        },
      });
    }

    if (ul.querySelector('[data-testid="standup-item"]')) return;

    function createDropdownItem(label, testid, onClick) {
      const li = document.createElement("li");
      li.className = "gl-new-dropdown-item";
      li.setAttribute("tabindex", "0");
      li.setAttribute("data-testid", testid);

      const btn = document.createElement("button");
      btn.className = "gl-new-dropdown-item-content";
      btn.type = "button";
      btn.tabIndex = -1;

      const wrapper = document.createElement("span");
      wrapper.className = "gl-new-dropdown-item-text-wrapper";

      const textDiv = document.createElement("div");
      textDiv.className = "gl-new-dropdown-item-text";
      textDiv.textContent = label;

      wrapper.appendChild(textDiv);
      btn.appendChild(wrapper);
      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        onClick();
      });
      li.appendChild(btn);
      return li;
    }

    const standupItem = createDropdownItem(
      "Start standup",
      "standup-item",
      () => {
        startStandup();
      }
    );

    ul.appendChild(standupItem);
  }

  const dropdownObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (!(node instanceof HTMLElement)) return;
        if (node.matches && node.matches(".gl-new-dropdown-panel")) {
          insertShowTasksToggle();
        }
      });
    });
  });
  const dropdownTarget = document.body || document.documentElement || document;
  dropdownObserver.observe(dropdownTarget, { childList: true, subtree: true });

  insertShowTasksToggle();

  /* ------------------------------------------------------------------------
   *  Standup helper
   * ---------------------------------------------------------------------- */

  let standupState = null;

  function ensureStandupStyles() {
    if (document.getElementById("iqnox--standup-css")) return;
    const style = document.createElement("style");
    style.id = "iqnox--standup-css";
    style.textContent = `
      .iqnox--standup-overlay {
        position: fixed;
        bottom: 16px;
        right: 16px;
        width: 260px;
        max-width: 260px;
        border: 1px solid rgba(15, 23, 42, 0.08);
        border-radius: 12px;
        padding: 14px;
        box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
        z-index: 10000;
        font-size: 0.85em;
        font-family: Inter, sans-serif;
        background: var(--gl-bg-color, #ffffff);
        color: var(--gl-text-color, #111);
      }
      html.gl-dark .iqnox--standup-overlay {
        background: var(--gl-dark-bg, #080d17);
        color: #f8fafc;
      }
      .iqnox--task-hidden {
        display: none !important;
      }
      .iqnox--quick-filter-hidden {
        display: none !important;
      }
      .iqnox--standup-hidden {
        display: none !important;
      }
      .iqnox--board-loading {
        position: fixed;
        top: 16px;
        right: 16px;
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 8px 12px;
        border-radius: 999px;
        border: 1px solid rgba(15, 23, 42, 0.1);
        background: rgba(255, 255, 255, 0.96);
        color: var(--gl-text-color, #111);
        font-size: 0.78rem;
        font-weight: 700;
        box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14);
        z-index: 10001;
      }
      .iqnox--board-loading::before {
        content: "";
        width: 10px;
        height: 10px;
        border-radius: 999px;
        border: 2px solid rgba(59, 130, 246, 0.25);
        border-top-color: #2563eb;
        animation: iqnox-spin 0.8s linear infinite;
      }
      .iqnox--board-loading[hidden] {
        display: none !important;
      }
      html.gl-dark .iqnox--board-loading {
        border-color: rgba(255, 255, 255, 0.12);
        background: rgba(17, 24, 39, 0.96);
        color: #f8fafc;
      }
      @keyframes iqnox-spin {
        to {
          transform: rotate(360deg);
        }
      }
      .iqnox--standup-highlight {
        outline: 2px solid #fb923c;
        box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45);
        background-color: rgba(251, 113, 25, 0.25) !important;
        transform: scale(1.02);
        transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
      }
      html.gl-dark .iqnox--standup-highlight {
        outline-color: #fbbf24;
        background-color: rgba(59, 130, 246, 0.24) !important;
      }
      .iqnox--parent-highlight {
        outline: 3px solid var(--gl-accent, #38bdf8);
        box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.8);
        transform: scale(1.01);
        transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
      }
      .iqnox--child-item {
        display: flex;
        align-items: center;
        gap: 10px;
        flex-wrap: nowrap;
        color: inherit;
        text-decoration: none;
      }
      .iqnox--child-item:hover {
        text-decoration: none;
        opacity: 0.92;
      }
      .iqnox--child-content {
        flex: 1;
        min-width: 0;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 6px;
      }
      .iqnox--child-avatar {
        width: 24px;
        height: 24px;
        border-radius: 50%;
        overflow: hidden;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
      }
      .iqnox--child-avatar img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }
      .iqnox--child-name {
        font-weight: 600;
        flex: 1;
        min-width: 0;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .iqnox--child-status-badge {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        padding: 2px 6px;
        border-radius: 999px;
        font-size: 0.65rem;
        letter-spacing: 0.05em;
        border: 1px solid rgba(148, 163, 184, 0.35);
        background: rgba(255, 255, 255, 0.9);
        color: var(--iqnox-status-color, #0f172a);
      }
      html.gl-dark .iqnox--child-status-badge {
        border-color: rgba(255, 255, 255, 0.25);
        background: white;
      }
      .iqnox--child-status-text {
        display: inline-flex;
      }
      .iqnox--parent-info {
        font-size: 0.85em;
        margin-bottom: 8px;
        margin-left: 12px;
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
      }
      .iqnox--stale-badge {
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        margin: 0;
        padding: 0;
        border: 0;
        background: transparent;
        color: var(--gl-text-color-subtle, var(--gl-text-color, #6b7280));
        font-size: 12px;
        font-weight: inherit;
        line-height: inherit;
        cursor: help;
        white-space: nowrap;
        font-family: inherit;
      }
      .iqnox--stale-badge-dot {
        width: 0.5rem;
        height: 0.5rem;
        border-radius: 999px;
        flex-shrink: 0;
        background: currentColor;
        opacity: 1;
      }
      .iqnox--stale-badge-text {
        display: inline-flex;
        min-width: 0;
        font-size: 12px;
        line-height: inherit;
        font-weight: inherit;
      }
      .iqnox--stale-badge[data-state="fresh"] {
        color: #047857;
      }
      .iqnox--stale-badge[data-state="aging"] {
        color: #c2410c;
      }
      .iqnox--stale-badge[data-state="stale"] {
        color: #b91c1c;
      }
      .iqnox--info-pill {
        padding: 2px 8px;
        border-radius: 999px;
        font-size: 0.75rem;
        font-weight: 500;
        display: inline-flex;
        align-items: center;
        gap: 6px;
      }
      .iqnox--parent-pill {
        background-color: var(--blue-50, #e0f2fe);
        color: var(--blue-700, #0369a1);
      }
      .iqnox--epic-pill {
        background-color: var(--purple-50, #ede9fe);
        color: var(--purple-700, #5b21b6);
      }
      .iqnox--locate-parent-btn {
        border: none;
        background: transparent;
        color: inherit;
        font-size: 0.65rem;
        cursor: pointer;
        text-decoration: underline;
        position: relative;
        z-index: 3;
        pointer-events: auto;
      }
      .iqnox--tasks-count {
        font-size: 0.75em;
        margin: 4px 12px 8px;
        padding: 4px 6px;
        border-radius: 6px;
        display: block;
        box-sizing: border-box;
        position: relative;
        z-index: 3;
        cursor: pointer;
        overflow: visible;
      }
      .iqnox--tasks-count[data-state="done"] {
        background-color: var(--green-50, #ecfdf3);
        color: var(--green-700, #047857);
      }
      .iqnox--tasks-count[data-state="pending"] {
        background-color: var(--yellow-50, #fef9c3);
        color: var(--yellow-800, #92400e);
      }
      .iqnox--tasks-count__header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 2px;
      }
      .iqnox--tasks-count__progress {
        position: relative;
        width: 100%;
        height: 4px;
        border-radius: 999px;
        background: rgba(0, 0, 0, 0.08);
        overflow: hidden;
      }
      .iqnox--tasks-count__progress-bar {
        width: 0;
        height: 100%;
        border-radius: 999px;
        transition: width 0.3s ease;
      }
      .iqnox--tasks-count__avatars {
        display: none;
        align-items: center;
        gap: 4px;
        margin-top: 6px;
      }
      .iqnox--tasks-count:hover .iqnox--tasks-count__avatars,
      .iqnox--tasks-count:focus-within .iqnox--tasks-count__avatars {
        display: flex;
      }
      .iqnox--tasks-count__avatar {
        width: 18px;
        height: 18px;
        border-radius: 999px;
        overflow: hidden;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-size: 0.65rem;
        font-weight: 700;
        background: rgba(15, 23, 42, 0.12);
        color: inherit;
      }
      .iqnox--tasks-count__avatar img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }
      .iqnox--tasks-count__avatar-more {
        font-size: 0.65rem;
        font-weight: 700;
        opacity: 0.8;
      }
      .iqnox--tasks-count__tooltip {
        display: none;
        position: absolute;
        left: 0;
        top: calc(100% + 6px);
        min-width: 260px;
        max-width: 320px;
        padding: 8px;
        border-radius: 10px;
        border: 1px solid rgba(15, 23, 42, 0.12);
        background: var(--gl-bg-color, #ffffff);
        box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18);
        z-index: 20;
      }
      .iqnox--tasks-count:hover .iqnox--tasks-count__tooltip,
      .iqnox--tasks-count:focus-within .iqnox--tasks-count__tooltip {
        display: block;
      }
      html.gl-dark .iqnox--tasks-count__tooltip {
        background: #111827;
        border-color: rgba(255, 255, 255, 0.12);
        box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
      }
      .iqnox--tasks-count__tooltip-header {
        font-size: 0.68rem;
        font-weight: 700;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        margin-bottom: 6px;
        opacity: 0.8;
      }
      .iqnox--tasks-count__tooltip-row {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 4px 2px;
        border-radius: 6px;
        color: inherit;
        text-decoration: none;
      }
      .iqnox--tasks-count__tooltip-row:hover {
        background: rgba(15, 23, 42, 0.05);
        text-decoration: none;
      }
      html.gl-dark .iqnox--tasks-count__tooltip-row:hover {
        background: rgba(255, 255, 255, 0.06);
      }
      .iqnox--tasks-count__tooltip-more {
        margin-top: 4px;
        font-size: 0.68rem;
        opacity: 0.75;
      }
      .iqnox--tasks-count[data-state="done"] .iqnox--tasks-count__progress-bar {
        background: #22c55e;
      }
      .iqnox--tasks-count[data-state="pending"] .iqnox--tasks-count__progress-bar {
        background: #f97316;
      }
      .iqnox--standup-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        margin-bottom: 6px;
      }
      .iqnox--standup-title-row {
        display: inline-flex;
        align-items: center;
        gap: 6px;
      }
      .iqnox--standup-title {
        font-weight: 700;
        font-size: 1rem;
      }
      .iqnox--standup-mode-toggle {
        display: inline-flex;
        align-items: center;
        gap: 1px;
        padding: 1px;
        border-radius: 999px;
        border: 1px solid rgba(148, 163, 184, 0.35);
        background: rgba(15, 23, 42, 0.04);
      }
      .iqnox--standup-mode-button {
        border: none;
        background: transparent;
        color: var(--gl-text-color, #111);
        font-size: 0.75rem;
        line-height: 1;
        padding: 3px 6px;
        border-radius: 999px;
        cursor: pointer;
        opacity: 0.7;
      }
      .iqnox--standup-mode-button.is-active {
        background: rgba(99, 102, 241, 0.18);
        opacity: 1;
      }
      html.gl-dark .iqnox--standup-mode-toggle {
        border-color: rgba(255, 255, 255, 0.2);
        background: rgba(255, 255, 255, 0.06);
      }
      html.gl-dark .iqnox--standup-mode-button.is-active {
        background: rgba(99, 102, 241, 0.3);
        color: #f8fafc;
      }
      .iqnox--standup-button-group {
        display: flex;
        align-items: center;
        gap: 6px;
      }
      .iqnox--standup-actions {
        display: flex;
        align-items: center;
        gap: 4px;
        margin-left: auto;
      }
      .iqnox--standup-icon-btn {
        width: 28px;
        height: 28px;
        padding: 0;
        border-radius: 999px;
        border: 1px solid rgba(15, 23, 42, 0.15);
        background: rgba(15, 23, 42, 0.02);
        color: var(--gl-text-color, #111);
        font-size: 0.9rem;
        line-height: 1;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        box-shadow: none;
      }
      .iqnox--standup-icon-btn:hover {
        background: rgba(15, 23, 42, 0.06);
      }
      html.gl-dark .iqnox--standup-icon-btn {
        border-color: rgba(255, 255, 255, 0.2);
        background: rgba(255, 255, 255, 0.06);
        color: #f8fafc;
      }
      html.gl-dark .iqnox--standup-icon-btn:hover {
        background: rgba(255, 255, 255, 0.12);
      }
      .iqnox--standup-current {
        font-size: 0.95rem;
        font-weight: 600;
        margin-bottom: 10px;
      }
      .iqnox--standup-loading {
        display: none;
        align-items: center;
        gap: 6px;
        font-size: 0.75rem;
        margin: 6px 0 10px;
        color: var(--gl-text-color, #6b7280);
      }
      html.gl-dark .iqnox--standup-loading {
        color: rgba(248, 250, 252, 0.7);
      }
      .iqnox--standup-list {
        list-style: none;
        margin: 0 0 10px 0;
        padding: 0;
        max-height: 300px;
        overflow-y: auto;
      }
      .iqnox--standup-assignee {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 4px 0;
        cursor: pointer;
        white-space: nowrap;
        transition: opacity 0.2s ease;
        opacity: 0.6;
      }
      .iqnox--standup-assignee.iqnox--standup-active {
        font-weight: 700;
        text-decoration: underline;
        opacity: 1;
      }
      .iqnox--standup-avatar {
        width: 22px;
        height: 22px;
        border-radius: 50%;
        flex-shrink: 0;
      }
      .iqnox--standup-nav {
        display: flex;
        gap: 8px;
        margin: 6px 0 0;
      }
      .iqnox--standup-button {
        flex: 1;
        height: 36px;
        background: #ffffff;
        color: var(--gl-text-color, #111);
        border: 1px solid rgba(15, 23, 42, 0.15);
        box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
        border-radius: 10px;
        font-size: 1.1rem;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        transition: transform 0.12s ease, background 0.2s ease;
      }
      html.gl-dark .iqnox--standup-button {
        background: #111827;
        border-color: rgba(255, 255, 255, 0.1);
        color: #f8fafc;
        box-shadow: 0 6px 15px rgba(15, 23, 42, 0.4);
      }
      .iqnox--standup-button:hover {
        transform: translateY(-1px);
      }
      .iqnox--standup-button:active {
        transform: scale(0.96);
      }
      .iqnox--standup-control {
        width: 28px;
        height: 28px;
        padding: 0;
      }
      .iqnox--standup-close {
        font-size: 1rem;
      }
    `;
    document.head.appendChild(style);
  }

  function gatherAssigneesAndCards() {
    const mapping = {};
    const avatarCache = {}; // stores avatar URLs for each user
    const labelCache = {};
    const cards = document.querySelectorAll(CARD_SELECTOR);

    cards.forEach((card) => {
      if (card.classList.contains(TASK_HIDDEN_CLASS)) return;

      const avatars = getCardAssigneeImages(card);
      const seenForCard = new Set();

      avatars.forEach((img) => {
        const assignee = getAssigneeIdentityParts(img.getAttribute("alt"));
        const avatarKey = normalizeAvatarIdentity(img.src);
        const key = avatarKey || assignee.key;
        if (!key) return;

        if (seenForCard.has(key)) return;
        seenForCard.add(key);

        if (!mapping[key]) mapping[key] = [];
        mapping[key].push(card);
        labelCache[key] = choosePreferredAssigneeLabel(
          labelCache[key],
          assignee
        );

        // Save avatar URL (only first avatar per assignee)
        if (!avatarCache[key]) {
          avatarCache[key] = img.src;
        }
      });
    });

    return { mapping, avatarCache, labelCache };
  }

  /**
   * Create an SVG progress ring to visualize the completion percentage of a user's tasks.
   * @param {number} open Number of open tasks
   * @param {number} total Total number of tasks
   * @returns {SVGElement}
   */
  function createProgressRing(open, total) {
    // percentage closed: completed = total - open
    const pct = total === 0 ? 0 : Math.round(((total - open) / total) * 100);
    const radius = 8;
    const circ = 2 * Math.PI * radius;
    const offset = circ - (pct / 100) * circ;
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("width", "20");
    svg.setAttribute("height", "20");
    const bg = document.createElementNS(svg.namespaceURI, "circle");
    bg.setAttribute("cx", "10");
    bg.setAttribute("cy", "10");
    bg.setAttribute("r", radius);
    bg.setAttribute("stroke", "var(--gl-border-color, #d1d5db)");
    bg.setAttribute("stroke-width", "3");
    bg.setAttribute("fill", "none");
    const fg = document.createElementNS(svg.namespaceURI, "circle");
    fg.setAttribute("cx", "10");
    fg.setAttribute("cy", "10");
    fg.setAttribute("r", radius);
    fg.setAttribute("stroke", "var(--green-600, #16a34a)");
    fg.setAttribute("stroke-width", "3");
    fg.setAttribute("fill", "none");
    fg.setAttribute("stroke-dasharray", circ);
    fg.setAttribute("stroke-dashoffset", offset);
    fg.setAttribute("transform", "rotate(-90 10 10)");
    svg.appendChild(bg);
    svg.appendChild(fg);
    return svg;
  }

  function highlightTasksForAssignee(assignee, mapping) {
    document
      .querySelectorAll(".iqnox--standup-highlight")
      .forEach((el) => el.classList.remove("iqnox--standup-highlight"));

    const cards = mapping[assignee] || [];
    cards.forEach((card) => {
      card.classList.add("iqnox--standup-highlight");
    });

    if (cards.length > 0) {
      cards[0].scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
    }
  }

  function flashCardHighlight(card) {
    if (!card) return;
    card.scrollIntoView({ behavior: "smooth", block: "center" });
    card.classList.add("iqnox--parent-highlight");
    setTimeout(() => card.classList.remove("iqnox--parent-highlight"), 2000);
  }

  function findCardByTitle(title) {
    if (!title) return null;
    const target = title.trim().toLowerCase();
    return (
      Array.from(document.querySelectorAll(".board-card-title"))
        .map((titleEl) => ({
          text: titleEl.textContent?.trim().toLowerCase(),
          card: titleEl.closest(".board-card"),
        }))
        .find((item) => item.text && item.text.includes(target))?.card || null
    );
  }

  /* ---- Standup order persistence ---- */

  function getStandupOrderKey() {
    return `iqnox--standup-order-${getBoardId()}`;
  }

  function getStandupModeKey() {
    return `iqnox--standup-mode-${getBoardId()}`;
  }

  function loadStandupMode() {
    const raw = localStorage.getItem(getStandupModeKey());
    return raw === "filter" ? "filter" : "highlight";
  }

  function saveStandupMode(mode) {
    localStorage.setItem(getStandupModeKey(), mode === "filter" ? "filter" : "highlight");
  }

  function saveStandupOrder(list) {
    try {
      localStorage.setItem(getStandupOrderKey(), JSON.stringify(list));
    } catch (_) {}
  }

  function loadStandupOrder(defaultList) {
    const raw = localStorage.getItem(getStandupOrderKey());
    if (!raw) return defaultList;
    try {
      const saved = JSON.parse(raw);
      // Filter out users no longer on board
      return saved.filter((x) => defaultList.includes(x));
    } catch (_) {
      return defaultList;
    }
  }

  function toggleFocusMode() {
    const button = document.querySelector('[data-testid="focus-mode-button"]');
    button?.click();
  }

  function getBoardLists() {
    const lists = new Set();
    document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
      const list = card.closest("ul, ol");
      if (list) lists.add(list);
    });
    document.querySelectorAll('li.board-list-count[data-issue-id="-1"]').forEach((node) => {
      const list = node.closest("ul, ol");
      if (list) lists.add(list);
    });
    return Array.from(lists);
  }

  function findHorizontalScroller() {
    const candidates = [
      '[data-testid="issue-board"]',
      ".boards-app",
      ".boards-list",
      ".board-list",
      ".board",
    ];
    for (const selector of candidates) {
      const el = document.querySelector(selector);
      if (el && el.scrollWidth > el.clientWidth + 5) return el;
    }
    const all = Array.from(document.querySelectorAll("div"));
    return all.find((el) => el.scrollWidth > el.clientWidth + 20) || document.body;
  }

  async function waitForHorizontalScroller(shouldAbort) {
    let attempts = 0;
    while (attempts < 20) {
      if (shouldAbort?.()) return null;
      const scroller = findHorizontalScroller();
      if (scroller && scroller.scrollWidth > scroller.clientWidth + 5) return scroller;
      await new Promise((resolve) => setTimeout(resolve, 200));
      attempts += 1;
    }
    return findHorizontalScroller();
  }

  async function ensureColumnsRendered(shouldAbort) {
    const scroller = await waitForHorizontalScroller(shouldAbort);
    if (!scroller) return;
    const max = scroller.scrollWidth - scroller.clientWidth;
    if (max <= 0) return;

    const isWindowScroller =
      scroller === document.body || scroller === document.documentElement;
    const setScrollLeft = (value) => {
      if (isWindowScroller) {
        window.scrollTo(value, window.scrollY);
      } else {
        scroller.scrollLeft = value;
      }
    };

    const step = Math.max(240, Math.floor(scroller.clientWidth * 0.9));
    for (let x = 0; x <= max; x += step) {
      if (shouldAbort?.()) return;
      setScrollLeft(x);
      await new Promise((resolve) => setTimeout(resolve, 350));
    }
    setScrollLeft(max);
    await new Promise((resolve) => setTimeout(resolve, 500));
    setScrollLeft(0);
  }

  function findScrollContainer(element) {
    let current = element;
    while (current && current !== document.body) {
      if (current.scrollHeight > current.clientHeight + 5) return current;
      current = current.parentElement;
    }
    return null;
  }

  function isListFullyLoaded(list) {
    const countNode = list.querySelector('li.board-list-count[data-issue-id="-1"] span');
    if (!countNode) return true;
    const text = countNode.textContent?.toLowerCase() || "";
    return text.includes("showing all");
  }

  async function loadAllBoardColumns(shouldAbort) {
    await ensureColumnsRendered(shouldAbort);
    if (shouldAbort?.()) return;
    const lists = getBoardLists();
    if (lists.length === 0) return;

    const horizontalScroller = await waitForHorizontalScroller(shouldAbort);
    const isWindowScroller =
      !horizontalScroller ||
      horizontalScroller === document.body ||
      horizontalScroller === document.documentElement;

    const getColumnElement = (list) =>
      list.closest(
        '[data-testid="board-list"], .board-list, .gl-issue-board-list, .gl-board-list'
      ) || list;

    for (const list of lists) {
      if (shouldAbort?.()) return;
      const columnEl = getColumnElement(list);
      if (columnEl && columnEl.scrollIntoView) {
        columnEl.scrollIntoView({ behavior: "instant", inline: "center", block: "nearest" });
        await new Promise((resolve) => setTimeout(resolve, 250));
      } else if (horizontalScroller && !isWindowScroller && columnEl) {
        const parentRect = horizontalScroller.getBoundingClientRect();
        const colRect = columnEl.getBoundingClientRect();
        const offset = colRect.left - parentRect.left;
        horizontalScroller.scrollLeft += offset;
        await new Promise((resolve) => setTimeout(resolve, 250));
      }

      const scroller = findScrollContainer(list) || list;
      let attempts = 0;
      while (!isListFullyLoaded(list) && attempts < 60) {
        if (shouldAbort?.()) return;
        scroller.scrollTop = scroller.scrollHeight;
        await new Promise((resolve) => setTimeout(resolve, 250));
        attempts += 1;
      }
    }
  }

  async function startStandup() {
    if (standupState) return;

    let mapping = {};
    let avatarCache = {};
    let labelCache = {};
    const captureAssignees = () => {
      const result = gatherAssigneesAndCards();
      mapping = result.mapping;
      avatarCache = result.avatarCache;
      labelCache = result.labelCache;
      return Object.keys(mapping).filter(Boolean);
    };
    let assignees = [];
    let index = 0;

    // --- Overlay container ---
    const overlayFragment = templateFragment(`
      <div class="iqnox--standup-overlay">
        <div class="iqnox--standup-header">
          <div class="iqnox--standup-title-row">
            <div class="iqnox--standup-title">Standup</div>
            <div class="iqnox--standup-mode-toggle" data-standup-mode-toggle>
              <button class="iqnox--standup-mode-button is-active" data-standup-mode-highlight type="button" title="Highlight mode">
                ✨
              </button>
              <button class="iqnox--standup-mode-button" data-standup-mode-filter type="button" title="Filter mode">
                🔎
              </button>
            </div>
          </div>
          <div class="iqnox--standup-actions">
            <div class="iqnox--standup-button-group">
              <button class="iqnox--standup-icon-btn iqnox--standup-control"
                data-standup-refresh
                title="Refresh assignees"
                type="button"
              >⟳</button>
              <button class="iqnox--standup-icon-btn iqnox--standup-control"
                data-standup-randomize
                title="Randomize order"
                type="button"
              >⤮</button>
              <button class="iqnox--standup-icon-btn iqnox--standup-close"
                data-standup-close
                title="Close standup"
                type="button"
              >×</button>
            </div>
          </div>
        </div>
        <div class="iqnox--standup-loading" data-standup-loading>⏳ Loading all columns…</div>
        <div class="iqnox--standup-current" data-standup-current></div>
        <ul class="iqnox--standup-list" data-standup-list></ul>
        <div class="iqnox--standup-nav">
          <button class="iqnox--standup-button" data-standup-prev type="button">◀️</button>
          <button class="iqnox--standup-button" data-standup-next type="button">▶️</button>
        </div>
      </div>
    `);
    const overlay = overlayFragment.firstElementChild;
    if (!overlay) return;
    document.body.appendChild(overlay);
    standupState = { overlay, loading: true };
    toggleFocusMode();
    const nameElem = overlay.querySelector("[data-standup-current]");
    const modeHighlightBtn = overlay.querySelector("[data-standup-mode-highlight]");
    const modeFilterBtn = overlay.querySelector("[data-standup-mode-filter]");
    const assigneeList = overlay.querySelector("[data-standup-list]");
    const loadingEl = overlay.querySelector("[data-standup-loading]");
    const refreshBtn = overlay.querySelector("[data-standup-refresh]");
    const randBtn = overlay.querySelector("[data-standup-randomize]");
    const closeBtn = overlay.querySelector("[data-standup-close]");
    const prevBtn = overlay.querySelector("[data-standup-prev]");
    const nextBtn = overlay.querySelector("[data-standup-next]");
    if (
      !nameElem ||
      !modeHighlightBtn ||
      !modeFilterBtn ||
      !assigneeList ||
      !loadingEl ||
      !refreshBtn ||
      !randBtn ||
      !closeBtn ||
      !prevBtn ||
      !nextBtn
    )
      return;

    let standupMode = loadStandupMode();

    function clearStandupFilter() {
      document
        .querySelectorAll(`.${STANDUP_HIDDEN_CLASS}`)
        .forEach((el) => el.classList.remove(STANDUP_HIDDEN_CLASS));
    }

    function clearStandupHighlights() {
      document
        .querySelectorAll(".iqnox--standup-highlight")
        .forEach((el) => el.classList.remove("iqnox--standup-highlight"));
    }

    function applyStandupMode(user) {
      if (standupMode === "filter") {
        clearStandupHighlights();
        const visibleCards = new Set(mapping[user] || []);
        document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
          if (visibleCards.has(card)) {
            card.classList.remove(STANDUP_HIDDEN_CLASS);
          } else {
            card.classList.add(STANDUP_HIDDEN_CLASS);
          }
        });
        return;
      }

      clearStandupFilter();
      highlightTasksForAssignee(user, mapping);
    }

    function setStandupMode(mode) {
      standupMode = mode === "filter" ? "filter" : "highlight";
      saveStandupMode(standupMode);
      modeHighlightBtn.classList.toggle("is-active", standupMode === "highlight");
      modeFilterBtn.classList.toggle("is-active", standupMode === "filter");
      const user = assignees[index];
      applyStandupMode(user);
    }

    modeHighlightBtn.addEventListener("click", () => setStandupMode("highlight"));
    modeFilterBtn.addEventListener("click", () => setStandupMode("filter"));

    closeBtn.addEventListener("click", () => {
      clearStandupFilter();
      clearStandupHighlights();
      overlay.remove();
      standupState = null;
    });

    loadingEl.style.display = "flex";
    await loadAllBoardColumns(() => !standupState || standupState.overlay !== overlay);
    if (!standupState || standupState.overlay !== overlay) return;
    loadingEl.style.display = "none";

    assignees = captureAssignees();
    if (assignees.length === 0) {
      alert("No assignees found on this board.");
      overlay.remove();
      standupState = null;
      return;
    }

    // Load saved order, or randomize & save if none
    const hadSavedOrder = !!localStorage.getItem(getStandupOrderKey());
    assignees = loadStandupOrder(assignees);
    if (!hadSavedOrder) {
      assignees = assignees.sort(() => Math.random() - 0.5);
      saveStandupOrder(assignees);
    }

    // Build list items with avatar, progress ring and counts
    function addAssigneeListItem(user) {
      const cards = mapping[user] || [];
      const displayName = labelCache[user] || user;
      const { open: openCount, closed: closedCount } = computeCounts(cards);
      const total = openCount + closedCount;
      const fragment = templateFragment(`
        <li class="iqnox--standup-assignee">
          <img class="iqnox--standup-avatar" />
          <span class="iqnox--standup-text"></span>
        </li>
      `);
      const li = fragment.querySelector("li");
      if (!li) return;
      li.dataset.assignee = user;
      const avatar = li.querySelector("img");
      if (avatar) {
        avatar.src = avatarCache[user] || "";
        avatar.alt = displayName;
      }
      const text = li.querySelector(".iqnox--standup-text");
      const ring = createProgressRing(openCount, total);
      if (text) {
        li.insertBefore(ring, text);
        text.textContent = `${displayName} (${openCount}/${total})`;
      } else {
        li.appendChild(ring);
      }
      li.addEventListener("click", () => {
        const idx = assignees.indexOf(user);
        if (idx !== -1) {
          index = idx;
          showCurrent();
        }
      });
      assigneeList.appendChild(li);
    }

    assignees.forEach(addAssigneeListItem);

    function rebuildAssigneeList() {
      assigneeList.innerHTML = "";
      assignees.forEach(addAssigneeListItem);
    }

    function reorderAssignees({ randomize } = {}) {
      const refreshedAssignees = captureAssignees();
      if (refreshedAssignees.length === 0) {
        alert("No assignees found on this board.");
        return false;
      }

      if (randomize) {
        assignees = refreshedAssignees.sort(() => Math.random() - 0.5);
        localStorage.removeItem(getStandupOrderKey());
      } else {
        const savedOrder = loadStandupOrder(refreshedAssignees);
        const missing = refreshedAssignees.filter(
          (name) => !savedOrder.includes(name)
        );
        assignees = [...savedOrder, ...missing];
      }

      saveStandupOrder(assignees);
      rebuildAssigneeList();
      index = 0;
      showCurrent();

      if (standupState) {
        standupState.mapping = mapping;
        standupState.avatarCache = avatarCache;
        standupState.labelCache = labelCache;
        standupState.assignees = assignees;
        standupState.index = index;
      }
      return true;
    }

    // Highlight current user in list
    function updateListHighlight(user) {
      Array.from(assigneeList.children).forEach((li) => {
        li.classList.toggle("iqnox--standup-active", li.dataset.assignee === user);
      });
    }

    // Compute open/closed tasks using the isCardClosed helper. Cards may not carry a data-board-type attribute, so rely on our helper.
    function computeCounts(cards) {
      let open = 0;
      let closed = 0;
      cards.forEach((card) => {
        const closedFlag = card.parentElement?.getAttribute("data-board-type") === "closed";
        if (closedFlag) {
          closed++;
        } else {
          open++;
        }
      });
      return { open, closed };
    }

    // Update the UI for the currently selected user
    function showCurrent() {
      const user = assignees[index];
      const cards = mapping[user];
      const displayName = labelCache[user] || user;
      const { open, closed } = computeCounts(cards);

      nameElem.textContent = `${displayName} — ${open} open / ${open + closed} total`;

      updateListHighlight(user);
      applyStandupMode(user);
    }

    // Button actions
    prevBtn.onclick = () => {
      index = (index - 1 + assignees.length) % assignees.length;
      showCurrent();
    };
    nextBtn.onclick = () => {
      index = (index + 1) % assignees.length;
      showCurrent();
    };

    // Randomize order
    randBtn.onclick = () => reorderAssignees({ randomize: true });

    refreshBtn.onclick = () => reorderAssignees();


    standupState = { mapping, avatarCache, labelCache, assignees, index, overlay };
    setStandupMode(standupMode);
    showCurrent();
  }

  /* ------------------------------------------------------------------------
   *  Entry point
   * ---------------------------------------------------------------------- */

  function runWhenDomReady(fn) {
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", fn, { once: true });
    } else {
      fn();
    }
  }

  runWhenDomReady(() => {
    enforceIssueAndTaskFilter();
    setupMutationObserver();
    ensureStandupStyles();
    if (mineFilterActive) {
      toggleMineFilter(true);
    }
  });
})();