// ==UserScript==
// @name Devabit Jira+
// @namespace http://tampermonkey.net/
// @version 2.5.5
// @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) {
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 === "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 fature_updateJiraTime() {
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 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.red; // red
el.style.color = "#ffffff";
} else {
el.style.backgroundColor = jiraColors.green; // green
el.style.color = "#ffffff";
}
});
}
// 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 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();
// }
fature_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);
})();