Toggl Copy Report Totals v1.0.0

← Back to User Scripts

This script adds copy buttons to each metric container in Toggl reports (total hours, billable hours, amount, average daily hours), allowing you to quickly copy the totals to your clipboard with a single click. The script uses robust class name matching that ignores generated hash parts for compatibility with Toggl's dynamic CSS.

Script Content

// ==UserScript==
// @name         Toggl: Copy Report Totals
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.1
// @description  Adds copy buttons to each metric container in Toggl reports to copy the totals
// @author       Tim Hilton using Copilot
// @match        https://track.toggl.com/reports/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Toggl: Copy Report Totals]';

    const COPY_ICON_SVG = `

`;

    const TICK_ICON_SVG = `

    

`;

    const injectStyles = () => {
        console.debug(`${LOG_PREFIX} Injecting styles`);
        
        const style = document.createElement('style');
        style.textContent = `
            [class*="MetricContainer"] {
                position: relative;
            }
            .toggl-copy-icon {
                border-radius: 4px;
                transition: background-color 0.25s ease-in-out;
                cursor: pointer;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 24px;
                height: 24px;
                position: absolute;
                right: 8px;
                top: 50%;
                transform: translateY(-50%);
            }
            .toggl-copy-icon:hover {
                background-color: rgba(0, 0, 0, 0.1);
            }
            .toggl-copy-icon svg path {
                fill: currentColor;
            }
        `;
        document.head.appendChild(style);
    };

    const handleCopyClick = (icon, text) => {
        console.debug(`${LOG_PREFIX} Copy button clicked for: ${text}`);
        
        navigator.clipboard.writeText(text).then(() => {
            const originalContent = icon.innerHTML;
            icon.innerHTML = TICK_ICON_SVG;
            console.log(`${LOG_PREFIX} ✅ Text copied: ${text}`);
            setTimeout(() => {
                if (document.body.contains(icon)) {
                    icon.innerHTML = originalContent;
                }
            }, 1000);
        }).catch(err => {
            console.log(`${LOG_PREFIX} ❌ Failed to copy text: ${err}`);
        });
    };

    const createCopyButton = (text) => {
        const button = document.createElement('span');
        button.innerHTML = COPY_ICON_SVG;
        button.classList.add('toggl-copy-icon');
        button.setAttribute('title', 'Copy to clipboard');
        button.addEventListener('click', () => handleCopyClick(button, text));
        return button;
    };

    const setupCopyButtons = () => {
        console.debug(`${LOG_PREFIX} Setting up copy buttons`);
        
        // Find all MetricContainer elements by looking for elements whose class contains "MetricContainer"
        const metricContainers = Array.from(document.querySelectorAll('[class*="MetricContainer"]'));
        
        console.debug(`${LOG_PREFIX} Found ${metricContainers.length} metric containers`);
        
        let addedCount = 0;
        metricContainers.forEach(container => {
            // Skip if we've already added a button to this container
            if (container.querySelector('.toggl-copy-icon')) {
                return;
            }

            // Find the ValueContainer element within the MetricContainer
            const valueContainer = container.querySelector('[class*="ValueContainer"]');
            if (!valueContainer) {
                return;
            }

            // Get the text content, handling cases where there might be nested elements
            const text = valueContainer.textContent.trim();
            if (!text) {
                return;
            }

            // Create and append the copy button to the container (not the valueContainer)
            const copyButton = createCopyButton(text);
            container.appendChild(copyButton);
            addedCount++;
        });
        
        if (addedCount > 0) {
            console.log(`${LOG_PREFIX} ✅ Added ${addedCount} copy buttons`);
        }
    };

    // Inject styles
    injectStyles();

    // Initial setup
    console.debug(`${LOG_PREFIX} Initializing script`);
    setupCopyButtons();

    // Use MutationObserver to handle dynamic content
    let observerFireCount = 0;
    const observer = new MutationObserver(() => {
        observerFireCount++;
        console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${observerFireCount})`);
        setupCopyButtons();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });

    console.log(`${LOG_PREFIX} 🎉 Script initialized successfully`);
})();