Devabit Jira+

Jira enhancements.

// ==UserScript==
// @name         Devabit Jira+
// @namespace    http://tampermonkey.net/
// @version      2.5.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() {
    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.whiteSpace = "normal";
        el.style.backgroundColor = "";
        return;
      }

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