jira-extensions

Jira enhancements.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         jira-extensions
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Jira enhancements.
// @match        https://devabit.atlassian.net/browse/*
// @match        https://devabit.atlassian.net/jira/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const deadlineMap = 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 feature_hideElements() {
    let selectors = [
      'span[data-testid="atlassian-navigation.ui.conversation-assistant.app-navigation-ai-mate"]',
      "div._1e0c1txw._vchhusvi._gy1pu2gc._1p57u2gc._4cvr1h6o._2lx2vrvc._kqswh2mm",
      'div[data-testid="issue.views.issue-base.foundation.status.actions-wrapper"]',
      'div[data-testid="issue.views.issue-base.foundation.status.improve-issue"]',
      "div._19itglyw._vchhusvi._r06hglyw._2rko1qi0._1dqonqa1._189ee4h9._1h6d1l7x._1e0c1txw._2lx21bp4._bfhkvuon",
      "div._19itglyw._vchhusvi._r06hglyw._2rko1qi0._189ee4h9._1dqonqa1._1h6d1l7x._1rjcutpp._18zrutpp._1ul95x59",
      'div[data-testid="issue-smart-request-summary.ui.ai-container"]',
      'div[data-testid="issue-view-common-views.placeholder-template-header"]',
      'div[data-testid="issue-view-common-views.placeholder-template-main-container"]',
    ];

    if (!Array.isArray(selectors)) return;

    selectors.forEach((selector) => {
      const elements = document.querySelectorAll(selector);
      elements.forEach((el) => {
        el.style.display = "none";
      });
    });
  }

  function parseDateTimeString(str) {
    if (!str) return null;

    // Example: "19 Feb 2026, 16:00"
    const regex = /^(\d{1,2}) ([A-Za-z]{3}) (\d{4}), (\d{1,2}):(\d{2})$/;
    const m = str.match(regex);
    if (!m) return null;

    const [, day, monStr, year, hh, mm] = m;

    const month = [
      "Jan",
      "Feb",
      "Mar",
      "Apr",
      "May",
      "Jun",
      "Jul",
      "Aug",
      "Sep",
      "Oct",
      "Nov",
      "Dec",
    ].indexOf(monStr);

    if (month === -1) return null;

    return new Date(
      parseInt(year, 10),
      month,
      parseInt(day, 10),
      parseInt(hh, 10),
      parseInt(mm, 10),
      0,
    );
  }

  function formatTimeLeft(ms, showCountdown) {
    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(" ");

    // --- Format exact target date ---
    const target = new Date(Date.now() + ms);

    const uaMonths = [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December",
    ];

    const dd = target.getDate();
    const mm = uaMonths[target.getMonth()];
    const yyyy = target.getFullYear();
    const HH = target.getHours().toString().padStart(2, "0");
    const MM = target.getMinutes().toString().padStart(2, "0");

    // 12-hour format with AM/PM
    let hours12 = target.getHours() % 12;
    if (hours12 === 0) hours12 = 12;
    const ampm = target.getHours() >= 12 ? "PM" : "AM";
    const HH12 = hours12.toString().padStart(2, "0");

    const formattedDate24 = `${HH}:${MM}`;
    const formattedDate12 = `${HH12}:${MM} ${ampm}`;

    // Combine both formats
    const formattedDateCombined = `${dd} ${mm} ${yyyy} р., ${formattedDate24} (${formattedDate12})`;

    if (ms <= 0) {
      return showCountdown
        ? `${formattedDateCombined}\n(over deadline by ${label})`
        : `${formattedDateCombined}`;
    } else {
      return showCountdown
        ? `${formattedDateCombined}\n(left ${label})`
        : `${formattedDateCombined}`;
    }
  }

  function jiraTimeToHours(input) {
    if (!/\d/.test(input)) return null; // ignore if no digits

    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 === "Archive" ||
        el.innerText === "Scheduled")
    );
  }

  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 feature_highlightIfPOisReceived() {
    const spans = document.querySelectorAll(
      'div[data-testid="issue.views.field.checkbox-inline-edit.customfield_10357--container"] > form > div._1pfhu2gc > div._vwz4kb7n > div > div > div > div > div > div >  span > span._11c82smr._1reo15vq._18m915vq._p12fmgvx._1bto1l2s._o5721q9c',
    );
    if (!spans.length) return;

    spans.forEach((span) => {
      const value = span.textContent.trim().toLowerCase();
      const parent = span.parentElement?.closest("span");
      if (!parent) return;

      parent.style.border = "none";
      parent.style.borderRadius = "4px";

      if (value === "yes") {
        parent.style.backgroundColor = jiraColors.green;
        parent.style.color = "#ffffff";
      } else if (value === "no") {
        parent.style.backgroundColor = jiraColors.red;
        parent.style.color = "#ffffff";
      } else {
        parent.style.backgroundColor = "";
        parent.style.color = "";
      }
    });
  }

  function feature_updateJiraTime() {
    const selectors = [
      ".css-v44io0",
      "._19pkidpf._2hwxidpf._otyridpf._18u0idpf._1i4qfg65._11c8wadc._y3gn1h6o",
      "._19pkidpf._2hwxidpf._otyridpf._18u0idpf._1i4qfg65._11c81o8v._y3gn1h6o",
      "span > span > ._k48p1wq8",
      '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 if no digits in string
      if (!/\d/.test(original)) return;

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

      const hours = jiraTimeToHours(original);
      if (hours == null) return;

      el.textContent = `${hours}h`;
    });
  }

  function feature_setupLiveDeadlineCountdown() {
    const containers = document.querySelectorAll(
      'div[data-testid="issue-field-date-time.ui.issue-field-date-time--container"] > div',
    );
    containers.forEach((el) => {
      if (!deadlineMap.has(el)) {
        const original = el.textContent.trim();

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

      if (isDueDeadlineApply()) {
        el.innerText = `${formatTimeLeft(msLeft, true)}`;
        el.style.whiteSpace = "pre";
        el.style.flexDirection = "column";
        el.style.gap = "0rem";
        el.style.alignItems = "flex-start";
      } else {
        el.textContent = `${formatTimeLeft(msLeft, false)}`;
        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.yellow; // yellow
        el.style.color = "#000000";
      } else {
        el.style.backgroundColor = jiraColors.green; // green
        el.style.color = "#ffffff";
      }
    });
  }

  function runAllEnhancements() {
    feature_hideElements();
    feature_highlightIfPOisReceived();
    feature_setupLiveDeadlineCountdown();
    feature_updateLiveDeadlineCountdown();
    feature_highlightIfMonthBilledIsIncorrect();
    feature_updateJiraTime();
  }

  setInterval(runAllEnhancements, 150);
})();