Devabit Jira+

Jira enhancements.

Stan na 18-07-2025. Zobacz najnowsza wersja.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         Devabit Jira+
// @namespace    http://tampermonkey.net/
// @version      2.5
// @description  Jira enhancements.
// @match        https://devabit.atlassian.net/browse/*
// @match        https://devabit.atlassian.net/jira/*
// @grant        none
// @license      3-clause BSD
// ==/UserScript==

(function () {
  "use strict";

  const deadlineMap = new Map();
  const estimatedHoursMap = new Map();

  const uaMonths = {
    січ: 0,
    лют: 1,
    бер: 2,
    квіт: 3,
    трав: 4,
    черв: 5,
    лип: 6,
    серп: 7,
    вер: 8,
    жовт: 9,
    лист: 10,
    груд: 11,
  };

  const jiraColors = {
    red: "#a10a0a",
    green: "#315e1d",
    yellow: "#ffd414",
    white: "#ffffff",
    black: "#000000",
  };

  function parseDateString(str) {
    // Parses date like "16 лип. 2025 р." or "16 лип. 2025 р., 17:00"
    // Ignores time after comma
    const regex = /(\d{1,2})\s([а-яіїєґ]{3})\.?\s(\d{4})/i;
    const m = regex.exec(str);
    if (!m) return null;
    return {
      day: +m[1],
      month: m[2].toLowerCase(),
      year: +m[3],
    };
  }

  function parseDateTimeString(str) {
    const regex =
      /(\d{1,2})\s([а-яіїєґ]{3,5})\.?\s(\d{4})\sр\.,?\s(\d{1,2}):(\d{2})/i;
    const m = regex.exec(str);
    if (!m) return null;
    const day = parseInt(m[1], 10);
    const month = uaMonths[m[2].toLowerCase()];
    const year = parseInt(m[3], 10);
    const hour = parseInt(m[4], 10);
    const minute = parseInt(m[5], 10);
    if (month === undefined) return null;
    return new Date(year, month, day, hour, minute);
  }

  function formatTimeLeft(ms) {
    const absMs = Math.abs(ms);
    const totalSeconds = Math.floor(absMs / 1000);
    const totalMinutes = Math.floor(totalSeconds / 60);
    const totalHours = Math.floor(totalMinutes / 60);
    const totalDays = Math.floor(totalHours / 24);
    const totalWeeks = Math.floor(totalDays / 7);
    const totalMonths = Math.floor(totalDays / 30);

    let parts = [];

    if (totalMonths >= 1) {
      parts.push(`${totalMonths}mo`);
      const remainingDays = totalDays % 30;
      if (remainingDays) parts.push(`${remainingDays}d`);
    } else if (totalWeeks >= 1) {
      parts.push(`${totalWeeks}w`);
      const remainingDays = totalDays % 7;
      if (remainingDays) parts.push(`${remainingDays}d`);
    } else {
      const hours = Math.floor((totalSeconds % 86400) / 3600);
      const minutes = Math.floor((totalSeconds % 3600) / 60);
      const seconds = totalSeconds % 60;

      if (totalDays) parts.push(`${totalDays}d`);
      if (hours) parts.push(`${hours}h`);
      if (minutes) parts.push(`${minutes}m`);
      if (seconds || parts.length === 0)
        parts.push(`${seconds.toString().padStart(2, "0")}s`);
    }

    const label = parts.join(" ");

    if (ms <= 0) {
      return `over deadline by ${label}`;
    } else {
      return `${label} left`;
    }
  }

  function datesMatch(date1, date2) {
    return (
      date1 &&
      date2 &&
      date1.day === date2.day &&
      date1.month === date2.month &&
      date1.year === date2.year
    );
  }

  function jiraTimeToHours(input) {
    const timeUnits = {
      w: 40,
      d: 8,
      h: 1,
      m: 1 / 60,
    };
    const regex = /(\d+)\s*(w|d|h|m)/gi;
    let totalHours = 0,
      match;
    while ((match = regex.exec(input)) !== null) {
      totalHours += parseInt(match[1], 10) * timeUnits[match[2].toLowerCase()];
    }
    return +totalHours.toFixed(2);
  }

  function isDueDeadlineApply() {
    const el = document.querySelector(
      'button[id="issue.fields.status-view.status-button"] > span.css-178ag6o'
    );
    return !(
      el &&
      (el.innerText === "Delivered" ||
        el.innerText === "Within client" ||
        el.innerText === "Scheduled")
    );
  }

  function feature_highlighIfDeadlineAndDueDateMismatch() {
    if (!isDueDeadlineApply()) return;

    const dueDateContainer = document.querySelector(
      'div[data-testid="coloured-due-date.ui.colored-due-date-container"]'
    );
    const dueDateSpan = document.querySelector(
      'div[data-testid="coloured-due-date.ui.colored-due-date-container"] > span'
    );

    if (!dueDateSpan) return;

    const officialDate = parseDateString(dueDateSpan.textContent.trim());
    if (!officialDate) return;

    deadlineMap.forEach((info, el) => {
      const originalDate = parseDateString(info.original);

      if (!datesMatch(originalDate, officialDate)) {
        dueDateContainer.style.backgroundColor = jiraColors.red; // red highlight
      } else {
        dueDateContainer.style.backgroundColor = ""; // clear highlight if matches
      }
    });
  }

  function feature_highlightIfMonthBilledIsIncorrect() {
    if (!isDueDeadlineApply()) return;

    const tags = document.querySelectorAll(
      'span[data-testid="issue.views.common.tag.tag-item"] > span'
    );
    const now = new Date();
    const currentMonth = now.toLocaleString("en-US", {
      month: "long",
    });
    const currentYear = now.getFullYear();

    tags.forEach((tag) => {
      // Check for Delivered in sibling span.css-178ag6o
      const parent = tag.closest(
        'span[data-testid="issue.views.common.tag.tag-item"]'
      );
      if (!parent) return;

      const deliveredSpan = parent.querySelector("span.css-178ag6o");
      if (deliveredSpan && deliveredSpan.textContent.includes("Delivered")) {
        // Skip highlighting & timer for this task
        parent.style.backgroundColor = "";
        parent.style.color = "";
        parent.style.border = "";
        return;
      }

      const text = tag.textContent.trim();
      const regex = /^([A-Za-z]+)\s+(\d{4})$/;
      const match = text.match(regex);
      if (!match) return;

      const [_, tagMonth, tagYearStr] = match;
      const tagYear = parseInt(tagYearStr, 10);

      parent.style.border = "none"; // remove border

      if (
        tagMonth.toLowerCase() === currentMonth.toLowerCase() &&
        tagYear === currentYear
      ) {
        parent.style.backgroundColor = jiraColors.green; // green
        parent.style.color = "white";
      } else {
        parent.style.backgroundColor = jiraColors.red; // red
        parent.style.color = "white";
      }
    });
  }

  function fature_updateJiraTime() {
    const selectors = [
      ".css-v44io0",
      'span[data-testid="issue.issue-view.common.logged-time.value"]',
      'span[data-testid="issue.component.logged-time.remaining-time"] > span',
    ];

    document.querySelectorAll(selectors.join(",")).forEach((el) => {
      let original = el.getAttribute("data-original");
      if (!original) {
        original = el.textContent.trim();
        el.setAttribute("data-original", original);
      }

      // Skip non-time strings in css-v44io0
      if (el.classList.contains("css-v44io0") && !/[wdhm]/i.test(original))
        return;

      const hours = jiraTimeToHours(original);
      el.textContent = `${hours}h`;

      // Save estimate if it’s the main estimate field
      if (el.classList.contains("css-v44io0")) {
        estimatedHoursMap.set("estimate", hours);
      }
    });
  }

  function feature_bailando() {
    const aEl = document.querySelector(
      'a[aria-label="Go to your Jira homepage"]'
    );
    if (aEl) {
      aEl.removeAttribute("style");
      aEl.style.textDecoration = "none";
    }

    const logoWrapper = document.querySelector(
      'span[data-testid="atlassian-navigation--product-home--logo--wrapper"]'
    );
    if (!logoWrapper) return;

    // Remove existing SVG
    const svg = logoWrapper.querySelector("svg");
    if (svg) svg.remove();

    // Use flex container
    logoWrapper.style.display = "flex";
    logoWrapper.style.alignItems = "center";
    logoWrapper.style.gap = "8px";

    // Add GIF only once
    if (!logoWrapper.querySelector("img.devabit-gif")) {
      const img = document.createElement("img");

      img.src = "https://media.tenor.com/vX-qFMkapQQAAAAj/cat-dancing.gif";

      img.className = "devabit-gif";
      img.style.height = "32px";
      img.style.width = "auto";
      img.style.verticalAlign = "middle";
      logoWrapper.appendChild(img);
    }

    // Add/update Жира span
    let textSpan = logoWrapper.querySelector("span.devabit-text");
    if (!textSpan) {
      textSpan = document.createElement("span");
      textSpan.className = "devabit-text";
      textSpan.textContent = "жира :)";
      textSpan.style.fontWeight = "700";
      textSpan.style.fontFamily = '"Atlassian Sans", Arial, sans-serif';
      textSpan.style.fontSize = "14px";
      textSpan.style.cursor = "pointer";
      textSpan.style.userSelect = "none";
      textSpan.style.paddingRight = "6px";
      textSpan.style.setProperty("text-decoration", "none", "important");
      logoWrapper.appendChild(textSpan);
    }

    textSpan.style.color = "#ffffff";
    textSpan.style.mixBlendMode = "difference";
  }

  function feature_setupLiveDeadlineCountdown() {
    const containers = document.querySelectorAll(
      'div[data-testid="issue-field-inline-edit-read-view-container.ui.container"]'
    );
    containers.forEach((el) => {
      if (!deadlineMap.has(el)) {
        let original = el.getAttribute("data-original");
        if (!original) {
          original = el.textContent.trim();
          el.setAttribute("data-original", original);
        }

        const deadline = parseDateTimeString(original);
        if (!deadline) return;

        deadlineMap.set(el, {
          deadline,
          original,
        });
      }
    });
  }

  function feature_updateLiveDeadlineCountdown() {
    if (!isDueDeadlineApply()) return;

    const now = new Date();

    deadlineMap.forEach((info, el) => {
      const msLeft = info.deadline - now;
      const hoursLeft = msLeft / (1000 * 60 * 60);

      let label = formatTimeLeft(msLeft);

      if (isDueDeadlineApply()) {
        el.innerText = `${info.original}\n(${label})`;
        el.style.whiteSpace = "pre-line";
        el.style.flexDirection = "column";
        el.style.gap = "0rem";
        el.style.alignItems = "flex-start";
      } else {
        el.textContent = `${info.original}`;
      }

      el.style.backgroundColor = "";

      if (msLeft <= 0) {
        el.style.backgroundColor = jiraColors.red;
        el.style.color = "#ffffff";
      } else if (hoursLeft < 0.5) {
        el.style.backgroundColor = jiraColors.red; // red
        el.style.color = "#ffffff";
      } else {
        el.style.backgroundColor = jiraColors.green; // green
        el.style.color = "#ffffff";
      }
    });
  }

  //   function findFolderPathMatchingProjectCode() {
  //     const heading = document.querySelector(
  //       'h1[data-testid="issue.views.issue-base.foundation.summary.heading"]'
  //     );
  //     if (!heading) return null;

  //     const title = heading.textContent.trim();

  //     const casePatterns = [
  //       /[A-Z]{2}-[A-Z]{3}\d{7}-\d{3}/g, // full code with -xxx
  //       /[A-Z]{2}-[A-Z]{3}\d{7}/g, // code without suffix
  //       /\d{4}\/\d{5,6}(?:\/#\d+)?/g,
  //     ];

  //     let fullProjectCode = null;
  //     for (const pattern of casePatterns) {
  //       const matches = title.match(pattern);
  //       if (matches?.length) {
  //         fullProjectCode = matches[matches.length - 1];
  //         break;
  //       }
  //     }

  //     if (!fullProjectCode) return null;

  //     // Remove invalid Windows path chars
  //     const cleanedCode = fullProjectCode.replace(/[<>:"/\\|?*]/g, "");
  //     const baseCode = cleanedCode.replace(/-\d{3}$/, ""); // strip -xxx if present

  //     const escapedBase = baseCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  //     const pattern = new RegExp(
  //       `M:\\\\[^\\s]*${escapedBase}(?:-\\d{3})?[^\\s]*`,
  //       "gi"
  //     );

  //     const root = document.querySelector("div.ak-renderer-document");
  //     if (!root) return null;

  //     const elements = root.querySelectorAll("p, span, div");
  //     for (const el of elements) {
  //       const text = el.innerText.trim();
  //       const match = text.match(pattern);
  //       if (match?.length) {
  //         return match[0]; // Return first matched path
  //       }
  //     }

  //     return null;
  //   }

  //   function insertCaseIdFromTitle() {
  //     const heading = document.querySelector(
  //       'h1[data-testid="issue.views.issue-base.foundation.summary.heading"]'
  //     );
  //     if (!heading) return;

  //     const title = heading.textContent.trim();

  //     const casePatterns = [
  //       /[A-Z]{2}-[A-Z]{3}\d{7}-\d{3}/g,
  //       /[A-Z]{2}-[A-Z]{3}\d{7}/g, // code without suffix
  //       /\d{4}\/\d{5,6}(?:\/#\d+)?/g,
  //     ];

  //     let match = null;
  //     for (const pattern of casePatterns) {
  //       const matches = title.match(pattern);
  //       if (matches && matches.length) {
  //         match = matches[matches.length - 1];
  //         break;
  //       }
  //     }

  //     if (!match) return;

  //     // Remove previous overlay if exists
  //     const existing = document.getElementById("case-id-overlay");
  //     if (existing) existing.remove();

  //     const deadlineEl = [...deadlineMap.keys()][0];
  //     const deadlineText = deadlineEl?.getAttribute("data-original") || "";

  //     const estimateEl = document.querySelector(
  //       'div[data-testid="issue-field-inline-edit-read-view-container.ui.container"] > span > span'
  //     );
  //     const estimateText = estimateEl?.textContent.trim() || "";

  //     const container = document.createElement("div");
  //     container.id = "case-id-overlay";
  //     Object.assign(container.style, {
  //       position: "fixed",
  //       bottom: "0",
  //       left: "0",
  //       zIndex: "99999",
  //       backgroundColor: "#000",
  //       color: "#fff",
  //       padding: "6px 14px 6px 6px",
  //       fontSize: "14px",
  //       fontFamily: "Arial, sans-serif",
  //       opacity: "0.95",
  //       borderTopRightRadius: "4px",
  //       userSelect: "text",
  //     });

  //     const table = document.createElement("table");
  //     Object.assign(table.style, {
  //       borderCollapse: "collapse",
  //       width: "100%",
  //     });

  //     function createRow(buttonText, valueText) {
  //       const tr = document.createElement("tr");

  //       const tdBtn = document.createElement("td");
  //       const btn = document.createElement("button");
  //       btn.textContent = buttonText;
  //       Object.assign(btn.style, {
  //         background: "#444",
  //         color: "#fff",
  //         border: "none",
  //         borderRadius: "2px",
  //         padding: "2px 8px",
  //         cursor: "pointer",
  //         fontSize: "13px",
  //         userSelect: "none",
  //         whiteSpace: "nowrap",
  //       });
  //       btn.addEventListener("click", () => {
  //         navigator.clipboard.writeText(valueText);
  //         btn.textContent = "Done";
  //         setTimeout(() => {
  //           btn.textContent = buttonText;
  //         }, 1000);
  //       });
  //       tdBtn.appendChild(btn);
  //       tdBtn.style.verticalAlign = "middle";

  //       const tdVal = document.createElement("td");
  //       tdVal.textContent = valueText;
  //       tdVal.style.fontWeight = "normal";
  //       tdVal.style.userSelect = "text";
  //       tdVal.style.verticalAlign = "middle";
  //       tdVal.style.padding = "0px";

  //       tr.appendChild(tdBtn);
  //       tr.appendChild(tdVal);

  //       return tr;
  //     }

  //     table.appendChild(createRow("Copy", match));
  //     if (deadlineText) table.appendChild(createRow("Copy", deadlineText));
  //     if (estimateText) table.appendChild(createRow("Copy", `${estimateText}`));
  //     const folder = findFolderPathMatchingProjectCode();
  //     if (folder) table.appendChild(createRow("Copy", folder));

  //     container.appendChild(table);
  //     document.body.appendChild(container);
  //   }

  function debounce(func, delay) {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => func.apply(this, args), delay);
    };
  }

  function isBrowsePage() {
    return /\/browse\/[A-Z]+-\d+/i.test(location.pathname);
  }

  function removeOverlayIfNotBrowse() {
    if (!isBrowsePage()) {
      // overlays to hide
      // document.getElementById("case-id-overlay")?.remove();
      // document.getElementById('jira-top-overlay')?.remove();
    }
  }

  window.addEventListener("popstate", removeOverlayIfNotBrowse);
  window.addEventListener("hashchange", removeOverlayIfNotBrowse);

  function runAllEnhancements() {
    // if (!isBrowsePage()) {
    //   removeOverlayIfNotBrowse();
    // }

    fature_updateJiraTime();
    feature_bailando();
    feature_setupLiveDeadlineCountdown();
    feature_updateLiveDeadlineCountdown();
    feature_highlightIfMonthBilledIsIncorrect();
    feature_highlighIfDeadlineAndDueDateMismatch();
  }

  const debouncedUpdate = debounce(runAllEnhancements, 300);

  const observer = new MutationObserver(debouncedUpdate);
  observer.observe(document.body, {
    childList: true,
    subtree: true,
  });

  window.addEventListener("load", runAllEnhancements);
  setInterval(feature_updateLiveDeadlineCountdown, 1000);
})();