Favro - Toggl Timer

Start Toggl Timer when viewing a Favro ticket

// ==UserScript==
// @name         Favro - Toggl Timer
// @namespace    https://www.gotom.io/
// @version      1.23.0
// @license      MIT
// @author       Mike Meier
// @match        https://favro.com/*
// @match        https://api.track.toggl.com/api/*
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.notification
// @grant        GM_addStyle
// @require      http://code.jquery.com/jquery-3.4.1.min.js
// @require      https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
// @resource     https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css
// @description Start Toggl Timer when viewing a Favro ticket
// ==/UserScript==
/* jshint esversion: 6 */
(function ($) {
    const style = `
        .favro-toggl-controls {
            position: absolute; 
            top: 12px; 
            left: 50%; 
            color: red;
            z-index:1000;
            display: inline-block;
            cursor: move;
        }
        
        .favro-toggl-controls--content {
            background-color: #f3f7fb;
            box-shadow: 0 2px 8px 0 rgba(0,0,0,.04);
            padding: 5px;
            border-radius: 4px;
            border: 1px solid #D3D3D3;
        }
        
        .favro-toggl-controls--content .fa {
            cursor: pointer;
            margin: 2px;
        }
        
        .favro-toggl-controls--divider {
            padding-left: 10px;
            margin-left: 6px;
            border-left: 1px solid #D3D3D3;
        }
        
        .favro-toggl-controls--recording {
            animation: favro-toggl-pulse 1s cubic-bezier(.5, 0, 1, 1) infinite alternate;  
        }
        
        @keyframes favro-toggl-pulse {
          from { opacity: 1; }
          to { opacity: 0; }
        }
    `;

    const FAVRO_EMAIL_KEY_NAME = 'favro_email';
    const FAVRO_API_KEY_NAME = 'favro_api_key';
    const FAVRO_TICKET_PREFIX_KEY_NAME = 'favro_ticket_prefix';
    const FAVRO_ORGANIZATION_ID_KEY_NAME = 'favro_organization_id';
    const FAVRO_COLUMNS_TO_TRACK_KEY_NAME = 'favro_columns_to_track';
    const FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME = 'favro_pid_custom_field_id';

    const FAVRO_API_BASE_URL = 'https://favro.com/api/v1';

    const TOGGL_API_KEY_NAME = 'toggl_api_key';
    const TOGGL_WID_KEY_NAME = 'toggl_wid';
    const TOGGL_API_BASE_URL = 'https://api.track.toggl.com/api/v9';

    const UI_POSITION_TOP_KEY_VALUE = 'ui_position_top';
    const UI_POSITION_LEFT_KEY_VALUE = 'ui_position_left';

    const APP_AUTO_TOGGL_KEY_NAME = 'app_auto_toggl';
    const APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS = 'app_wait_before_tracking_seconds';

    const APP_DEFAULT_WAIT_BEFORE_TRACKING = 5000;
    const APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY = 15000;
    const APP_INTERVAL_DETECT_OPEN_CARD_CHANGE = 5000;

    const TICKET_NAME_SUFFIX = ' (Auto-Toggl)';

    const ENV_VALUES = [
        FAVRO_EMAIL_KEY_NAME,
        FAVRO_API_KEY_NAME,
        FAVRO_TICKET_PREFIX_KEY_NAME,
        FAVRO_ORGANIZATION_ID_KEY_NAME,
        FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME,
        FAVRO_COLUMNS_TO_TRACK_KEY_NAME,

        TOGGL_API_KEY_NAME,
        TOGGL_WID_KEY_NAME,

        APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS
    ];

    function start() {
        $.noConflict();
        ensureEnvironmentVariables();
        setupControlsContainer(setupCurrentTimeEntry);

        window.setInterval(setupCurrentTimeEntry, APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY);
        window.setInterval(detectOpenCardChanges(onOpenCardChange()), APP_INTERVAL_DETECT_OPEN_CARD_CHANGE);
        window.onbeforeunload = stopTimeEntry;
    }

    async function setupCurrentTimeEntry() {
        GM.xmlHttpRequest({
            method: 'GET',
            url: TOGGL_API_BASE_URL + '/me/time_entries/current',
            headers: await getTogglHeaders(),
            onload: (res) => {
                setCurrentTimeEntry(JSON.parse(res.response));
            }
        });
    }

    let controlsContainer = null;

    function setupControlsContainer(done) {
        $(function () {
            GM_addStyle(style);
            const container = `
                    <div id="favro-toggl-controls" class="favro-toggl-controls">
                        <div class="favro-toggl-controls--content">
                            <i class="fa fa-play-circle" data-favro-toggl-action="start"></i>
                            <i class="fa fa-stop-circle" data-favro-toggl-action="stop"></i>
                            <i class="fa fa-toggle-off favro-toggl-controls--divider" data-favro-toggl-action="toggl-auto"></i> Auto
                            <i class="fa fa-circle" data-recording-button></i>
                            <span data-recording-text></span>
                        </div>
                    </div>
                `;
            //favro-toggl-controls--recording
            $('body').prepend(container);
            controlsContainer = $('#favro-toggl-controls');
            adjustControlsContainerPosition(controlsContainer);

            controlsContainer.on('click', '[data-favro-toggl-action]', (e) => {
                const target = $(e.target);
                switch (target.data('favro-toggl-action')) {
                    case 'start':
                        if (currentOpenCardId) {
                            startTimeEntry(currentOpenCardId, true);
                        }
                        break;
                    case 'stop':
                        stopTimeEntry();
                        break;
                    case 'toggl-auto':
                        if (target.hasClass('fa-toggle-on')) {
                            GM.setValue(APP_AUTO_TOGGL_KEY_NAME, false);
                            target.removeClass('fa-toggle-on').addClass('fa-toggle-off');
                            stopTimeEntry();
                        } else {
                            GM.setValue(APP_AUTO_TOGGL_KEY_NAME, true);
                            target.removeClass('fa-toggle-off').addClass('fa-toggle-on');
                            if (currentOpenCardId) {
                                startTimeEntry(currentOpenCardId, true);
                            }
                        }
                        break;
                }
            });
            controlsContainer.draggable({
                stop: () => {
                    const pos = controlsContainer.position();
                    GM.setValue(UI_POSITION_TOP_KEY_VALUE, pos.top);
                    GM.setValue(UI_POSITION_LEFT_KEY_VALUE, pos.left);
                }
            });

            done();
        });
    }

    async function adjustControlsContainerPosition(controlsContainer) {
        const top = await GM.getValue(UI_POSITION_TOP_KEY_VALUE);
        const left = await GM.getValue(UI_POSITION_LEFT_KEY_VALUE);
        if (top > 0 && left > 0) {
            controlsContainer.css('top', top + 'px');
            controlsContainer.css('left', left + 'px');
        }

        if (await isAutoToggl()) {
            controlsContainer.find('.fa-toggle-off').removeClass('fa-toggle-off').addClass('fa-toggle-on');
        }
    }

    async function isAutoToggl() {
        return await GM.getValue(APP_AUTO_TOGGL_KEY_NAME) === true;
    }

    let currentTimeEntry = null;

    function setCurrentTimeEntry(newCurrentTimeEntry) {
        if ((newCurrentTimeEntry === null && currentTimeEntry === null) || newCurrentTimeEntry && currentTimeEntry && newCurrentTimeEntry.id === currentTimeEntry.id) {
            return;
        }

        currentTimeEntry = newCurrentTimeEntry;
        if (currentTimeEntry) {
            const description = currentTimeEntry.description.substr(0, 20).trim();
            controlsContainer.find('[data-recording-text]').html(description);
            controlsContainer.find('[data-recording-button]').addClass('favro-toggl-controls--recording');
        } else {
            controlsContainer.find('[data-recording-text]').html('');
            controlsContainer.find('[data-recording-button]').removeClass('favro-toggl-controls--recording');
        }
    }

    function updateControlsContainer() {
        if (!controlsContainer) {
            GM.notification({text: 'No controls available'});
            return;
        }
    }

    function ensureEnvironmentVariables() {
        ENV_VALUES.forEach(async key => {
            await GM.getValue(key, '__IS_NOT_SET') !== '__IS_NOT_SET' || GM.setValue(key, prompt(key));
        });
    }

    let currentOpenCardId = null;

    function detectOpenCardChanges(onChange) {
        return async () => {
            const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
            const regex = new RegExp('\\?card=' + ticketPrefix + '([\\d]+)', 'i');
            let search = new URL(location.href).search;
            let matches = regex.exec(search);
            let openCard = matches === null ? null : parseInt(matches[1]);
            if (openCard !== currentOpenCardId) {
                const oldValue = currentOpenCardId;
                currentOpenCardId = openCard;
                onChange(oldValue, openCard);
            }
        }
    }

    async function beforeSendFavro() {
        const favroToken = await GM.getValue(FAVRO_API_KEY_NAME);
        const email = await GM.getValue(FAVRO_EMAIL_KEY_NAME);
        const organizationId = await GM.getValue(FAVRO_ORGANIZATION_ID_KEY_NAME);

        return (xhr) => {
            xhr.setRequestHeader('Authorization', 'Basic ' + btoa(email + ':' + favroToken));
            xhr.setRequestHeader('organizationId', organizationId);
            xhr.setRequestHeader('Content-Type', 'application/json');
        }
    }

    async function getTogglHeaders() {
        const togglToken = await GM.getValue(TOGGL_API_KEY_NAME);

        return {
            'Authorization': 'Basic ' + btoa(togglToken + ':api_token'),
            'Content-Type': 'application/json'
        };
    }

    function getTogglPid(customFields, pidCustomFieldId) {
        if (!customFields) {
            return null;
        }

        let pid = null;
        customFields.forEach(customField => {
            if (customField.customFieldId === pidCustomFieldId) {
                pid = customField.total;
                return true;
            }
        });

        return pid;
    }

    let currentTimeEntryTimoutId = null;

    async function startTimeEntryForCard(card, doDelay) {
        const delay = doDelay ? await getTrackingWaitingTime() : 0;

        currentTimeEntryTimoutId = window.setTimeout(async () => {
            const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
            const ticketName = ticketPrefix + card.sequentialId;
            const description = ticketName + ' / ' + card.name + TICKET_NAME_SUFFIX;
            const pidCustomFieldId = await GM.getValue(FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME);
            const wid = parseInt(await GM.getValue(TOGGL_WID_KEY_NAME), 10);
            const pid = getTogglPid(card.customFields, pidCustomFieldId);
            const data = JSON.stringify({
                workspace_id: wid,
                project_id: pid,
                description: description,
                start: new Date().toISOString(),
                duration: -1,
                created_with: 'tampermonkey favro-toggl-timer ' + GM_info.script.version,
            });

            GM.xmlHttpRequest({
                method: 'POST',
                url: TOGGL_API_BASE_URL + '/workspaces/'+ wid +'/time_entries',
                data: data,
                headers: await getTogglHeaders(),
                onload: (res) => {
                    setCurrentTimeEntry(JSON.parse(res.response));
                },
                onerror: (res) => {
                    console.error(res);
                },
                onreadystatechange: (res) => {
                    console.error(res);
                }
            });
        }, delay);
    }

    async function getTrackingWaitingTime() {
        let waitingTime = parseInt(await GM.getValue(APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS));
        if (waitingTime > 0) {
            waitingTime = waitingTime * 1000;
        } else {
            waitingTime = 0;
        }

        if (waitingTime < 1000 || waitingTime > 300000) {
            return APP_DEFAULT_WAIT_BEFORE_TRACKING;
        }

        return waitingTime;
    }

    async function stopTimeEntry() {
        if (currentTimeEntryTimoutId) {
            window.clearTimeout(currentTimeEntryTimoutId);
        }

        if (!currentTimeEntry) {
            return;
        }

        const wid = parseInt(await GM.getValue(TOGGL_WID_KEY_NAME), 10);
        GM.xmlHttpRequest({
            method: 'PATCH',
            url: TOGGL_API_BASE_URL + '/workspaces/'+ wid +'/time_entries/' + currentTimeEntry.id + '/stop',
            headers: await getTogglHeaders(),
            onload: () => {
                setCurrentTimeEntry(null);
            }
        });
    }

    function isCardInTrackableColumn(card, columnsToTrack) {
        if (columnsToTrack.length === 0) {
            return true;
        }

        if (card.columnId && columnsToTrack.indexOf(card.columnId) !== -1) {
            return true;
        }

        let found = false;
        const selector = '.boardcolumn .carditem .card-title-text:contains(\'' + $.escapeSelector(card.name) + '\')';
        $(selector).parents('.boardcolumn').each((index, elem) => {
            const columnId = $(elem).attr('id');
            if (columnsToTrack.indexOf(columnId) !== -1) {
                return found = true;
            }
        });

        return found;
    }

    function onOpenCardChange() {
        return async (oldCardId, newCardId) => {
            if (!(await isAutoToggl())) {
                return;
            }

            await stopTimeEntry();
            if (newCardId) {
                await startTimeEntry(newCardId, false);
            }
        };
    }

    async function startTimeEntry(cardId, manualStarted) {
        const sequentialId = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME) + cardId;
        const columnsToTrackEnv = await GM.getValue(FAVRO_COLUMNS_TO_TRACK_KEY_NAME);

        let columnsToTrack = [];
        if (!manualStarted && typeof columnsToTrackEnv === "string" && columnsToTrackEnv !== "") {
            columnsToTrack = columnsToTrackEnv.split(',');
        }

        $.ajax({
            type: 'GET',
            url: FAVRO_API_BASE_URL + '/cards?cardSequentialId=' + sequentialId,
            beforeSend: await beforeSendFavro(),
            success: (res) => {
                const card = res.entities[0];
                if (!card) {
                    GM.notification({text: 'No card found in favro for sequentialId ' + sequentialId});
                    return;
                }

                if (!isCardInTrackableColumn(card, columnsToTrack)) {
                    return;
                }

                startTimeEntryForCard(card, !manualStarted);
            },
            error: err => {
                GM.notification({text: 'Card sequentialId ' + sequentialId + ' fetch error: ' + err});
            }
        });
    }

    start();
})(window.jQuery);