NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Toggl Timer Page Enhancement
// @description Highlights overlapping time entries and adds JIRA links in Timer page
// @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/timer
// @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.');
});
}
// Exit if no configuration
if (!jiraUrl || !maxHours) {
console.log('Toggl Enhancement: Configuration missing, script will not run');
return;
}
function findTimeEntryRoot(element) {
let current = element;
while (current && current.parentElement) {
if (current.parentElement.tagName.toLowerCase() === 'ul') {
return current;
}
current = current.parentElement;
}
return null;
}
function parseDate(dateText) {
const today = new Date();
const currentYear = today.getFullYear();
if (dateText.includes('Today')) {
return today.toLocaleDateString('en-CA'); // YYYY-MM-DD format
}
if (dateText.includes('Yesterday')) {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return yesterday.toLocaleDateString('en-CA');
}
const dateStr = dateText.split(',')[1].trim();
return `${currentYear} ${dateStr}`;
}
function getDurationInHours(start, end) {
return (end - start) / (1000 * 60 * 60);
}
function getCurrentEntry() {
const durationElement = document.querySelector('.time-format-utils__duration');
if (!durationElement) return null;
const now = new Date();
const duration = durationElement.textContent;
if (!duration) return null;
const parts = duration.replace(/[^0-9:]/g, '').split(':');
if (parts.length !== 3) return null;
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = parseInt(parts[2]);
const durationMs = (hours * 60 * 60 + minutes * 60 + seconds) * 1000;
const end = now;
const start = new Date(end - durationMs);
return {
element: durationElement.closest('div[class*="TimerFormContent"]'),
start: start,
end: end,
duration: durationMs / (1000 * 60 * 60)
};
}
function addJiraLinks() {
document.querySelectorAll('[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('[class*="TotalTime"]').forEach(element => {
const root = findTimeEntryRoot(element);
if (root) {
root.style.removeProperty('background-color');
}
});
// Reset current entry highlight
const currentEntryElement = document.querySelector('div[class*="TimerFormContent"]');
if (currentEntryElement) {
currentEntryElement.style.removeProperty('background-color');
}
const dateBlocks = document.querySelectorAll('[class*="ListTitle"]');
const timeEntries = [];
// Get current running entry
const currentEntry = getCurrentEntry();
if (currentEntry) {
timeEntries.push(currentEntry);
}
dateBlocks.forEach(dateBlock => {
const dateStr = parseDate(dateBlock.textContent);
const parentUl = dateBlock.closest('ul');
if (!parentUl) return;
const entries = parentUl.querySelectorAll('[class*="TotalTime"] span');
entries.forEach(timeElement => {
const timeString = timeElement.textContent;
if (!timeString) return;
const root = findTimeEntryRoot(timeElement);
if (!root) return;
const [start, end] = timeString.split(' - ');
const startDate = new Date(`${dateStr} ${start}`);
const endDate = new Date(`${dateStr} ${end}`);
timeEntries.push({
element: root,
start: startDate,
end: endDate,
duration: getDurationInHours(startDate, endDate)
});
});
});
timeEntries.forEach((entry, i) => {
if (entry.duration > maxHours) {
entry.element.style.setProperty('background-color', '#fff3cd', 'important');
}
for (let j = i + 1; j < timeEntries.length; j++) {
if (entry.start < timeEntries[j].end && timeEntries[j].start < entry.end) {
if (entry.element) {
entry.element.style.setProperty('background-color', '#ffebee', 'important');
}
if (timeEntries[j].element) {
timeEntries[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
});
})();