Devabit Jira+

Jira enhancements.

// ==UserScript==
// @name         Devabit Jira+
// @namespace    http://tampermonkey.net/
// @version      2.7
// @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) {
        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_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 feature_highlightIfPOisIncorrect() {
        const spans = document.querySelectorAll(
            '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",
            "span > 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`;

            // 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.yellow; // yellow
                el.style.color = "#ffffff";
            } else {
                el.style.backgroundColor = jiraColors.green; // green
                el.style.color = "#ffffff";
            }
        });
    }

function feature_insertInfoCopyButton() {
    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,
        /\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;

    const previousParent = document.querySelector(
        'div[data-testid="issue.views.issue-base.context.status-and-approvals-wrapper.status-and-approval"] > div'
    );
if (!previousParent) return;

const container = previousParent.parentElement;
if (!container) return;


    const existing = container.querySelector("#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 overlay = document.createElement("div");
    overlay.id = "case-id-overlay";
    overlay.style.display = "flex";
    overlay.style.flexDirection = "column";
    overlay.style.gap = "6px";
    overlay.style.marginBottom = "12px";

    function createRow(buttonText, valueText) {
        const row = document.createElement("div");
        row.style.display = "flex";
        row.style.alignItems = "center";
        row.style.gap = "8px";

        const btn = document.createElement("button");
        btn.textContent = buttonText;
        btn.className = "_mizu194a _1ah31bk5 _ra3xnqa1 _128m1bk5 _1cvmnqa1 _4davt94y _19itglyw _vchhusvi _r06hglyw _80omtlke _2rkosqtm _11c82smr _v5649dqc _189eidpf _1rjc12x7 _1e0c116y _1bsbviql _p12f1osq _kqswh2mm _4cvr1q9y _1bah1h6o _gy1p1b66 _1o9zidpf _4t3iviql _k48p1wq8 _y4tize3t _bozgze3t _y3gn1h6o _s7n4nkob _14mj1kw7 _9v7aze3t _1tv3nqa1 _39yqe4h9 _11fnglyw _18postnw _bfhk1w7a _syaz1gjq _8l3mmuej _aetrb3bt _10531gjq _f8pj1gjq _30l31gjq _9h8h1gjq _irr3166n _1di61dty _4bfu18uv _1hmsglyw _ajmmnqa1 _1a3b18uv _4fprglyw _5goinqa1 _9oik18uv _1bnxglyw _jf4cnqa1 _1nrm18uv _c2waglyw _1iohnqa1";
        btn.style.padding = "6px";
        btn.style.width = "fit-content";

        btn.addEventListener("click", () => {
            navigator.clipboard.writeText(valueText);
            btn.textContent = "Done";
            setTimeout(() => {
                btn.textContent = buttonText;
            }, 1000);
        });

        const text = document.createElement("span");
        text.textContent = valueText;
        text.style.userSelect = "text";
        text.style.fontWeight = "normal";
        text.style.flex = "1";

        row.appendChild(btn);
        row.appendChild(text);

        return row;
    }

    overlay.appendChild(createRow("Copy", match));
    if (deadlineText) overlay.appendChild(createRow("Copy", deadlineText));
    if (estimateText) overlay.appendChild(createRow("Copy", estimateText));

    container.appendChild(overlay);
}









    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();
        // }
        feature_insertInfoCopyButton();
        feature_highlightIfPOisIncorrect();
        feature_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);
})();