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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         GitLab Board Improvements
// @namespace    https://iqnox.com
// @version      0.12
// @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 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();
  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` : ""}`;
    pill.innerHTML = `<strong>${label}:</strong> ${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;
  }

  /* ------------------------------------------------------------------------
   *  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 getBoardShowTasks() {
    const value = localStorage.getItem(getBoardKey());
    // default: false (tasks hidden)
    return value === "true";
  }

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

  let showTasks = getBoardShowTasks();

  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;
      const parsed = parseItemFromUrl(link.href);
      if (parsed && parsed.type === "work_items") {
        setWorkItemVisibility(card);
      }
    });
  }

  /* ------------------------------------------------------------------------
   *  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;
  }

  /* ------------------------------------------------------------------------
   *  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;
  }

  /* ------------------------------------------------------------------------
   *  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
                  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,
              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 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 || parsed.type !== "issues") 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);
      }

      badge.innerHTML = "";
      const fragment = templateFragment(`
        <div class="iqnox--tasks-count__header">
          <span>Tasks</span>
          <span>${completed}/${total} (${pct}%)</span>
        </div>
        <div class="iqnox--tasks-count__progress">
          <div class="iqnox--tasks-count__progress-bar"></div>
        </div>
      `);
      badge.appendChild(fragment);
      const progressBar = badge.querySelector(".iqnox--tasks-count__progress-bar");
      if (progressBar) {
        progressBar.style.setProperty("width", `${pct}%`);
      }

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

      if (!badge.dataset.detailListenerAttached) {
        badge.dataset.detailListenerAttached = "true";
        badge.addEventListener("click", (event) => {
          event.stopPropagation();
          event.preventDefault();
          if (!showTasks) {
            showTasks = true;
            setBoardShowTasks(true);
            updateTaskVisibility();
          }
          renderChildDetails(card, key, children, badge);
        });
      }

    });
  }

  /* ------------------------------------------------------------------------
   *  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;

    card.dataset.parentInjected = "pending";

    if (parsed.type === "work_items") {
      // Task / work item
      setWorkItemVisibility(card);

      buildParentChainForTask(parsed.namespace, parsed.id).then((chain) => {
        if (!Array.isArray(chain) || chain.length === 0) {
          card.dataset.parentInjected = "done";
          return;
        }

        const info = document.createElement("div");
        info.className = "iqnox--parent-info";

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

        card.appendChild(info);
        card.dataset.parentInjected = "done";
      });
    } else if (parsed.type === "issues") {
      // Issues: epic + children
      Promise.all([
        fetchIssueEpic(parsed.namespace, parsed.id),
        fetchIssueChildren(parsed.namespace, parsed.id),
      ]).then(([epic]) => {
        const info = document.createElement("div");
        info.className = "iqnox--parent-info";

        if (epic) {
          const epicSpan = createInfoPill({
            label: "Epic",
            text: epic.title,
            variant: "epic",
          });
          info.appendChild(epicSpan);
        }

        if (info.childElementCount > 0) {
          card.appendChild(info);
        }

        card.dataset.parentInjected = "done";
        // Defer badge updates rather than applying immediately.
        scheduleTaskCountUpdate();
      });
    } else {
      card.dataset.parentInjected = "done";
    }
  }

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

  /* ------------------------------------------------------------------------
   *  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));
          }
        });
      });
    });

    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 hasShowTasks = ul.querySelector(
          '[data-testid="show-tasks-toggle-item"]'
        );
        const showLabelsItem = ul.querySelector(
          '[data-testid="show-labels-toggle-item"]'
        );
        if (!showLabelsItem) return;

        if (hasShowTasks) {
          insertAdditionalDropdownItems(ul);
          return;
        }

        const newItem = showLabelsItem.cloneNode(true);
        newItem.setAttribute("data-testid", "show-tasks-toggle-item");

        const labelSpan = newItem.querySelector(".gl-toggle-label");
        if (labelSpan) {
          labelSpan.textContent = "Show tasks";
          labelSpan.id = `toggle-label-show-tasks-${Date.now()}`;
        }

        const toggleButton = newItem.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 current = showTasks;
        const onIcon = "check-xs";
        const offIcon = "close-xs";

        function applyState(state) {
          toggleButton.setAttribute("aria-checked", state ? "true" : "false");
          toggleButton.classList.toggle("is-checked", state);
          const iconName = state ? onIcon : offIcon;
          if (useEl && iconBase) {
            useEl.setAttribute("href", `${iconBase}#${iconName}`);
          }
        }

        applyState(current);
        if (labelSpan)
          toggleButton.setAttribute("aria-labelledby", labelSpan.id);

        if (!toggleButton.dataset.tasksListenerAttached) {
          toggleButton.addEventListener("click", (event) => {
            event.stopPropagation();
            const currentState =
              toggleButton.getAttribute("aria-checked") === "true";
            const newValue = !currentState;
            showTasks = newValue;
            setBoardShowTasks(newValue);
            applyState(newValue);
            updateTaskVisibility();
          });
          toggleButton.dataset.tasksListenerAttached = "true";
        }

        showLabelsItem.parentNode.insertBefore(
          newItem,
          showLabelsItem.nextSibling
        );
        insertAdditionalDropdownItems(ul);
      });
    } catch (err) {
      console.warn("Error inserting show tasks toggle:", err);
    }
  }

  function insertAdditionalDropdownItems(ul) {
    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--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-details {
        margin: 0 12px 10px 12px;
        padding: 8px 10px;
        border-radius: 10px;
        border: 1px solid rgba(99, 102, 241, 0.35);
        background: rgba(99, 102, 241, 0.08);
        font-size: 0.75rem;
        color: var(--gl-text-color, #1f2937);
        display: flex;
        flex-direction: column;
        gap: 6px;
      }
      html.gl-dark .iqnox--child-details {
        border-color: rgba(148, 163, 184, 0.35);
        background: rgba(255, 255, 255, 0.04);
        color: var(--gl-text-color, #e3e8ff);
      }
      .iqnox--child-item {
        display: flex;
        align-items: center;
        gap: 10px;
        flex-wrap: nowrap;
      }
      .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 {
        font-size: 0.65rem;
        text-transform: uppercase;
        font-weight: 700;
        letter-spacing: 0.05em;
        color: #0f172a;
        white-space: nowrap;
      }
      .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--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;
      }
      .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[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;
        margin-bottom: 8px;
      }
      .iqnox--standup-title {
        font-weight: 700;
        font-size: 1rem;
      }
      .iqnox--standup-button-group {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .iqnox--standup-actions {
        display: flex;
        align-items: center;
        gap: 6px;
        margin-left: auto;
      }
      .iqnox--standup-current {
        font-size: 0.95rem;
        font-weight: 600;
        margin-bottom: 10px;
      }
      .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: 36px;
        height: 36px;
        padding: 0;
      }
      .iqnox--standup-close {
        background: transparent;
        border: none;
        font-size: 1.4rem;
        cursor: pointer;
        padding: 2px 6px;
        line-height: 1;
        color: var(--gl-text-color, #111);
      }
    `;
    document.head.appendChild(style);
  }

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

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

      const avatars = card.querySelectorAll("img[alt]");
      const seenForCard = new Set();

      avatars.forEach((img) => {
        let name = img.getAttribute("alt") || "";

        // Strip leading “Avatar for …”
        name = name.replace(/^Avatar for\s+/i, "").trim();
        if (!name) return;

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

        if (!mapping[name]) mapping[name] = [];
        mapping[name].push(card);

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

    return { mapping, avatarCache };
  }

  /**
   * 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 renderChildDetails(card, sourceKey, children, badge) {
    if (!card || !badge) return;
    const existing = card.querySelector(".iqnox--child-details");
    if (existing && existing.dataset.source === sourceKey) {
      existing.remove();
      return;
    }
    if (existing) existing.remove();
    if (!children || children.length === 0) return;

    const container = document.createElement("div");
    container.className = "iqnox--child-details";
    container.dataset.source = sourceKey;

      container.innerHTML = children
        .map((child) => {
          const assigneeLabel =
            child.assignees?.length > 0
              ? child.assignees.map((assignee) => assignee.username).join(", ")
              : "Unassigned";
          const statusLabel = child.statusName || "";
          const avatarSrc = child.assignees?.[0]?.avatarUrl || "";
          const statusColor = child.statusColor || "#0f172a";
          return `
            <div class="iqnox--child-item">
              <span class="iqnox--child-content">
                <span class="iqnox--child-name">${child.title}</span>
                 <span class="iqnox--child-status-badge" style="--iqnox-status-color:${statusColor}">
                   <span class="iqnox--child-status-text">${statusLabel || "Status unknown"}</span>
                 </span>
              </span>
              <span class="iqnox--child-avatar" title="${assigneeLabel}">
                ${avatarSrc ? `<img src="${avatarSrc}" alt="${assigneeLabel}" />` : ""}
              </span>
          </div>
        `;
      })
      .join("");

    badge.insertAdjacentElement("afterend", container);
  }

  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 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 startStandup() {
    if (standupState) return;

    let mapping = {};
    let avatarCache = {};
    const captureAssignees = () => {
      const result = gatherAssigneesAndCards();
      mapping = result.mapping;
      avatarCache = result.avatarCache;
      return Object.keys(mapping).filter(Boolean);
    };
    let assignees = captureAssignees();
    if (assignees.length === 0) {
      alert("No assignees found on this board.");
      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);
    }

    let index = 0;

    // --- Overlay container ---
    const overlayFragment = templateFragment(`
      <div class="iqnox--standup-overlay">
        <div class="iqnox--standup-header">
          <div class="iqnox--standup-title">Standup</div>
          <div class="iqnox--standup-actions">
            <div class="iqnox--standup-button-group">
              <button class="iqnox--standup-button iqnox--standup-control"
                data-standup-refresh
                title="Refresh assignees"
                type="button"
              >🔄</button>
              <button class="iqnox--standup-button iqnox--standup-control"
                data-standup-randomize
                title="Randomize order"
                type="button"
              >🎲</button>
              <button class="iqnox--standup-close"
                data-standup-close
                title="Close standup"
                type="button"
              >❌</button>
            </div>
          </div>
        </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;
    const nameElem = overlay.querySelector("[data-standup-current]");
    const assigneeList = overlay.querySelector("[data-standup-list]");
    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 || !assigneeList || !refreshBtn || !randBtn || !closeBtn || !prevBtn || !nextBtn) return;

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

    // Build list items with avatar, progress ring and counts
    function addAssigneeListItem(user) {
      const cards = mapping[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 = user;
      }
      const text = li.querySelector(".iqnox--standup-text");
      const ring = createProgressRing(openCount, total);
      if (text) {
        li.insertBefore(ring, text);
        text.textContent = `${user} (${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.assignees = assignees;
        standupState.index = index;
      }
      return true;
    }

    document.body.appendChild(overlay);

    // 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 { open, closed } = computeCounts(cards);

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

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

    // 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, assignees, index, overlay };
    showCurrent();
  }

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

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

  runWhenDomReady(() => {
    enforceIssueAndTaskFilter();
    setupMutationObserver();
    ensureStandupStyles();
    scanBoard();
    updateTaskVisibility();
    insertShowTasksToggle();
    // Schedule task badge updates to avoid thrashing
    scheduleTaskCountUpdate();
  });
})();