NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Toggl Detailed Report Enhancement
// @description Highlights overlapping time entries and adds JIRA links in Detailed Report
// @version 1.0.1
// @copyright 2024 - Milan Farkas, Claude / WRD Labs (https://wrd.hu)
// @namespace wrd.hu
// @author milanfarkas
// @license MIT
// @match https://track.toggl.com/reports/detailed/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function() {
'use strict';
// Check if we're running in Tampermonkey
const isTampermonkey = typeof GM_getValue === 'function' &&
typeof GM_setValue === 'function' &&
typeof GM_registerMenuCommand === 'function';
// Get or initialize configuration
let jiraUrl = isTampermonkey ? GM_getValue('jiraUrl') : null;
let maxHours = isTampermonkey ? GM_getValue('maxHours') : null;
// Ensure configuration exists
if (isTampermonkey && (!jiraUrl || !maxHours)) {
const newJiraUrl = prompt('Enter your JIRA instance URL (e.g., company.atlassian.net):', '');
if (!newJiraUrl) {
alert('JIRA URL is required. Script will not run.');
return;
}
const newMaxHours = prompt('Enter maximum hours before warning:', '');
if (!newMaxHours) {
alert('Maximum hours value is required. Script will not run.');
return;
}
const maxHoursNum = parseFloat(newMaxHours);
if (isNaN(maxHoursNum) || maxHoursNum <= 0) {
alert('Invalid hours value. Script will not run.');
return;
}
jiraUrl = newJiraUrl;
maxHours = maxHoursNum;
GM_setValue('jiraUrl', jiraUrl);
GM_setValue('maxHours', maxHours);
alert('Configuration saved! Script will now run.');
}
// Configuration menu command
if (isTampermonkey) {
GM_registerMenuCommand('Configure Toggl Enhancement', () => {
const newJiraUrl = prompt('Enter your JIRA instance URL (e.g., company.atlassian.net):', jiraUrl);
if (newJiraUrl === null) return;
const newMaxHours = prompt('Enter maximum hours before warning:', maxHours);
if (newMaxHours === null) return;
const maxHoursNum = parseFloat(newMaxHours);
if (isNaN(maxHoursNum) || maxHoursNum <= 0) {
alert('Invalid hours value. Configuration not saved.');
return;
}
jiraUrl = newJiraUrl;
maxHours = maxHoursNum;
GM_setValue('jiraUrl', jiraUrl);
GM_setValue('maxHours', maxHours);
alert('Configuration saved! Refresh the page to apply changes.');
});
}
function findTimeEntryRoot(element) {
return element.closest('tr');
}
function parseDate(dateStr) {
return dateStr;
}
function getDurationInHours(start, end) {
return (end - start) / (1000 * 60 * 60);
}
function addJiraLinks() {
document.querySelectorAll('div[class*="TimeEntryDescription"]').forEach(desc => {
if (desc.getAttribute('data-jira-processed')) return;
const text = desc.textContent;
const match = text.match(/^([A-Z0-9]+-\d+)/);
if (match) {
const ticketId = match[1];
const remainingText = text.substring(ticketId.length);
const span = document.createElement('span');
span.textContent = ticketId;
span.style.cssText = `
color: #0052CC;
text-decoration: none;
pointer-events: all;
cursor: pointer;
display: inline-block;
margin-right: 4px;
`;
span.onmouseover = () => span.style.textDecoration = 'underline';
span.onmouseout = () => span.style.textDecoration = 'none';
span.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
window.open(`https://${jiraUrl}/browse/${ticketId}`, '_blank');
return false;
};
span.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
}, true);
desc.textContent = '';
desc.appendChild(span);
desc.appendChild(document.createTextNode(remainingText));
desc.setAttribute('data-jira-processed', 'true');
}
});
}
function checkOverlaps() {
// First reset all highlights
document.querySelectorAll('tbody tr').forEach(row => {
row.style.removeProperty('background-color');
});
// Group entries by user and date
const entriesByUserAndDate = new Map();
document.querySelectorAll('tbody tr').forEach(row => {
const timeCell = row.querySelector('div[class*="Time-bodyText"]');
const dateCell = row.querySelector('div[class*="Date-bodyText"]');
const userCell = row.querySelector('td[class*="UserTableCell"]');
if (!timeCell || !dateCell || !userCell) return;
const user = userCell.textContent.trim();
const date = dateCell.textContent.trim();
const timeMatch = timeCell.textContent.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
if (!timeMatch) return;
const key = `${user}-${date}`;
if (!entriesByUserAndDate.has(key)) {
entriesByUserAndDate.set(key, []);
}
const [start, end] = timeMatch.slice(1);
const startDate = new Date(`${date} ${start}`);
const endDate = new Date(`${date} ${end}`);
entriesByUserAndDate.get(key).push({
element: row,
start: startDate,
end: endDate,
duration: getDurationInHours(startDate, endDate)
});
});
// Check overlaps within each user's daily entries
for (const entries of entriesByUserAndDate.values()) {
entries.forEach((entry, i) => {
if (entry.duration > maxHours) {
entry.element.style.setProperty('background-color', '#fff3cd', 'important');
}
for (let j = i + 1; j < entries.length; j++) {
if (entry.start < entries[j].end && entries[j].start < entry.end) {
entry.element.style.setProperty('background-color', '#ffebee', 'important');
entries[j].element.style.setProperty('background-color', '#ffebee', 'important');
}
}
});
}
// Add Jira links after processing entries
addJiraLinks();
}
// Run initial check after a short delay to ensure page is loaded
setTimeout(checkOverlaps, 2000);
const observer = new MutationObserver(() => {
setTimeout(checkOverlaps, 100);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();