Devabit Jira+

Jira enhancements.

As of 2025-07-17. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Devabit Jira+
// @namespace    http://tampermonkey.net/
// @version      2.3
// @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 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 parseDueDateString(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 datesMatch(date1, date2) {
        return date1 && date2 &&
            date1.day === date2.day &&
            date1.month === date2.month &&
            date1.year === date2.year;
    }

    function highlightIfDateMismatch() {
        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 = parseDueDateString(dueDateSpan.textContent.trim());
        if (!officialDate) return;

        deadlineMap.forEach((info, el) => {
            const originalDate = parseDueDateString(info.original);
            if (!datesMatch(originalDate, officialDate)) {
                dueDateContainer.style.backgroundColor = jiraColors.red; // red highlight
            } else {
                dueDateContainer.style.backgroundColor = ''; // clear highlight if matches
            }
        });
    }

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

    const estimatedHoursMap = new Map();

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



    function highlightTagsByDate() {
        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

            // TODO: handle passed months (do not highlight them red)

            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 updateTimeDisplays() {
        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 highlightTimeInSummary() {
        const heading = document.querySelector('h1[data-testid="issue.views.issue-base.foundation.summary.heading"]');
        if (!heading) return;
        const original = heading.getAttribute('data-original') || heading.textContent.trim();
        heading.setAttribute('data-original', original);

        const patterns = [
            /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}(?:,\s+\d{1,2}:\d{2})?(?:\s+GMT[+-]\d+)?/gi,
            /\b\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2})?/g,
            /\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}/g,
            /\b\d{1,2}:\d{2}\b/g
        ];

        let highlighted = original;
        patterns.forEach(pattern => {
            highlighted = highlighted.replace(pattern, match => `${match}`);
        });
        heading.innerText = highlighted;
    }

    function parseUADateTime(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 `🔥 ГОРИТЬ — просрочено на ${label}`;
            // return `over deadline by ${label}`;
        } else {
            return `${label} left`;
        }
    }




    const deadlineMap = new Map();

    function 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 = parseUADateTime(original);
                if (!deadline) return;

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

    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 updateLiveCountdowns() {
        if (isDelivered()) return;

        const now = new Date();
        const estimate = estimatedHoursMap.get('estimate') || 0;

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

            let label = formatTimeLeft(msLeft);
            if (!isDelivered()) {
                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}`
            }
            // Clear previous style
            // el.style.backgroundColor = '';

            // el.style.mixBlendMode = 'exclusion';
            
            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 if (hoursLeft < estimate) {
                el.style.backgroundColor = jiraColors.yellow; // yellow
                el.style.color = '#000000';
            } else {
                el.style.backgroundColor = jiraColors.green; // green
                el.style.color = '#ffffff';
            }

        });
    }

    function replaceLogoWithGif() {
        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 addWallpaperOverlay() {
        const existing = document.getElementById('jira-top-overlay');

        if (existing) {
            const img = existing.querySelector('img');
            if (img) img.style.opacity = opacity;
            return;
        }

        const container = document.createElement('div');
        container.id = 'jira-top-overlay';
        Object.assign(container.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100vw',
            height: '100vh',
            zIndex: '999999',
            pointerEvents: 'none',
            overflow: 'hidden'
        });

        const img = document.createElement('img');
        // winxp
        img.src = 'https://wallpaperswide.com/download/windows_xp_original-wallpaper-1920x1080.jpg';

        // winxp anime
        // img.src = 'https://c4.wallpaperflare.com/wallpaper/748/833/77/lucky-star-windows-xp-anime-izumi-konata-technology-windows-hd-art-wallpaper-preview.jpg';
        // steins;gate
        //img.src= 'https://wallpapercave.com/wp/wp1858920.jpg'
        // realmonke
        //img.src = 'https://www.gstatic.com/mail/themes/featured/f3.jpg=w1680-h1116-e365-fVignette=1,0,1.4,0,000000:Soften=1,0,0:-k-no-nd'

        Object.assign(img.style, {
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            opacity: '0.05'
        });

        container.appendChild(img);
        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()) {
            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();
            // return;
        }

        updateTimeDisplays();
        // highlightTimeInSummary();
        replaceLogoWithGif();
        setupLiveDeadlineCountdown();
        updateLiveCountdowns();
        highlightTagsByDate();
        highlightIfDateMismatch();
        // insertCaseIdFromTitle();
        addWallpaperOverlay();
    }

    const debouncedUpdate = debounce(runAllEnhancements, 300);

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

    window.addEventListener('load', runAllEnhancements);
    setInterval(updateLiveCountdowns, 1000);

})();