milanfarkas / Toggl Detailed Report Enhancement

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