Devabit Jira+

Jira enhancements.

Versión del día 15/7/2025. Echa un vistazo a la versión más reciente.

// ==UserScript==
// @name         Devabit Jira+
// @namespace    http://tampermonkey.net/
// @version      1.9
// @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

            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}`;
        } 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', `estimate ${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 = '';

            if (msLeft <= 0) {
                el.style.backgroundColor = jiraColors.red;
            } else if (hoursLeft < 0.5) {
                el.style.backgroundColor = jiraColors.red; // red
            } else if (hoursLeft < estimate) {
                el.style.backgroundColor = jiraColors.yellow; // yellow
                el.style.color = '#000000';
            } else {
                el.style.backgroundColor = jiraColors.green; // green
            }
        });
    }

    function debounce(func, delay) {
        let timer;
        return function() {
            clearTimeout(timer);
            timer = setTimeout(func, delay);
        };
    }

    const debouncedUpdate = debounce(() => {
        updateTimeDisplays();
        //highlightTimeInSummary();
        setupLiveDeadlineCountdown();
        updateLiveCountdowns();
        highlightTagsByDate();
        highlightIfDateMismatch();
        insertCaseIdFromTitle();
    }, 300);

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

    window.addEventListener('load', () => {
        updateTimeDisplays();
        //highlightTimeInSummary();
        setupLiveDeadlineCountdown();
        updateLiveCountdowns();
        insertCaseIdFromTitle();
    });

    setInterval(updateLiveCountdowns, 1000);
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。