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