milanfarkas / Toggl Timer Page Enhancement

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