Garmin Connect: Use speed targets in running workouts

Modifies the Connect workout page so running workouts use speed targets for display and configuration, in addition to pace targets

// ==UserScript==
// @name         Garmin Connect: Use speed targets in running workouts
// @namespace    http://tampermonkey.net/
// @description  Modifies the Connect workout page so running workouts use speed targets for display and configuration, in addition to pace targets
// @author       flowstate
// @match        https://connect.garmin.com/modern/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=garmin.com
// @grant        window.onurlchange
// @license      MIT
// @version      0.7
// ==/UserScript==

(function () {
    'use strict';

    let alreadyHere = false

    let tasks = [];

    oneTimeInit();

    function oneTimeInit() {
        waitForUrl();
    }

    function waitForUrl() {
        // if (window.onurlchange == null) {
            // feature is supported
            window.addEventListener('urlchange', onUrlChange);
        // }
        onUrlChange();
    }

    function onUrlChange() {
        const urlMatches = window.location.href.startsWith('https://connect.garmin.com/modern/workout/');
        if (!alreadyHere) {
            if (urlMatches) {
                alreadyHere = true;
                init();
            }
        } else {
            if (!urlMatches) {
                alreadyHere = false;
                deinit();
            }
        }
    }

    function init() {
        tasks = [];
        tasks.push(waitForElement(`.workoutPage`, waitForChanges));
    }

    function deinit() {
        tasks.forEach(task => task.stop());
        tasks = [];
        observer && observer.disconnect()
        observer = null

    }

    // function addStyle(styleString) {
    //     const style = document.createElement('style');
    //     style.textContent = styleString;
    //     document.head.append(style);
    // }

    function waitForElement(readySelector, callback) {
        let timer = undefined;

        const tryNow = function () {
            const elem = document.querySelector(readySelector);
            if (elem) {
                callback(elem);
            } else {
                timer = setTimeout(tryNow, 300);
            }
        };

        const stop = function () {
            clearTimeout(timer);
            timer = undefined;
        }

        tryNow();
        return {
            stop
        }
    }

    let observer

    function waitForChanges(element) {
        const config = { childList: true, subtree: true };

        // Callback function to execute when mutations are observed
        const callback = (mutationList, observer) => {
            onElementChanged()
        };

        // Create an observer instance linked to the callback function
        observer = new MutationObserver(callback);

        // Start observing the target node for configured mutations
        observer.observe(element, config);
    }

    function onElementChanged() {
        const stepDataFields = document.querySelectorAll("[class*=StepDataField_data]")
        for (const field of stepDataFields) {
            if (!field.getAttribute(speedModAttr)) {
                modifyStepDataField(field)
            }
        }
        const paceInputField = document.getElementById("target-pace-to")
        if (paceInputField) {
            modifyPaceInputField(paceInputField);
        }
    }

    function escapeRegExp(str) {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
    }

    function replaceAll(str, find, replace) {
        return str.replace(new RegExp(escapeRegExp(find), 'g'), replace);
    }

    const speedModAttr = "_speed-mod"
    function modifyStepDataField(element) {
        let innerText = element.innerText
        let metric = true
        // TODO fix this so it works for language which don't use "km"/"mi"
        if (innerText.includes("/km") || innerText.includes("/mi")) {
            if (innerText.includes("/mi")) {
                metric = false
                innerText = replaceAll(innerText, "mi", "km")
            }
            const exp = /(\d*:\d*) *\/km(\s)*\((\d*:\d*)-(\d*:\d*) *\/km\)/
            const matches = innerText.match(exp)
            if (matches.length < 5) {
                return
            }

            const target = matches[1]
            const targetLow = matches[3]
            const targetHigh = matches[4]
            const unit = metric ? "/km" : "/mi"
            // TODO see if localized strings can be used
            // (but Garmin uses "kph" which is frowned upon / nonstandard)
            const speedUnit = metric ? "km/h" : "mph"
            const newText =
`${target} ${unit}
(${targetLow} - ${targetHigh} ${unit})

${paceToSpeed(target)} ${speedUnit}
(${paceToSpeed(targetHigh)} - ${paceToSpeed(targetLow)} ${speedUnit})`

            element.innerText = newText
            element.setAttribute(speedModAttr, true)
        }
    }

    const paceModAttr = "_pace-mod"
    function modifyPaceInputField(element) {
        if (element.getAttribute(paceModAttr)) {
            return
        }
        element.setAttribute(paceModAttr, true)

        const wrapper = element.closest("[class*=RangeInput_content]");
        const clone = wrapper.cloneNode(true)

        const label = clone.querySelector("label")
        if (label) {
            label.innerText = "Speed"
        }

        let metric = true
        const originalUnitsLabel = wrapper.querySelector("[class*=TimeDurationInput_appendedLabel]")
        if (!originalUnitsLabel.innerText.includes("km")) {
            metric = false
        }
        const unitsLabel = clone.querySelector("[class*=TimeDurationInput_appendedLabel]")
        if (unitsLabel) {
            unitsLabel.innerText = metric ? "km/h" : "mph"
        }

        let minsFields = clone.querySelectorAll("[class*=TimeDurationInput_minutes]")
        for (const field of minsFields) {
            let html = field.parentNode.innerHTML
            html = replaceAll(html, ":", "")
            field.parentNode.innerHTML = html
        }
        minsFields = clone.querySelectorAll("[class*=TimeDurationInput_minutes]")
        for (const field of minsFields) {
            field.remove()
        }

        const secsFields = clone.querySelectorAll("[class*=TimeDurationInput_seconds]")
        // reverse order
        const fieldIds = ["_speed-mod-sec-max", "_speed-mod-sec-min"]
        // reverse order
        const paceFields = ["target-pace-input", "target-pace-to"]
        for (const field of secsFields) {
            field.style.width = "64px"
            field.setAttribute("placeholder", "")

            field.setAttribute("style",
`
    // background-color: inherit !important;
    caret-color: inherit !important;
    color: #555 !important;
    text-align: left !important;
    box-shadow: none !important;
    border: none !important;
    width: 64px !important;
    -webkit-user-select: text !important;
    -khtml-user-select: text !important;
    -moz-user-select: text !important;
    -o-user-select: text !important;
    user-select: text !important;
`)

            field.parentNode.setAttribute("style",
`
     -webkit-user-select: text !important;
    -khtml-user-select: text !important;
    -moz-user-select: text !important;
    -o-user-select: text !important;
    user-select: text !important;
`
            )

            field.id = fieldIds.pop()
            linkSpeedAndPaceField(field, paceFields.pop())

            if (fieldIds.length === 0) {
                break
            }
        }

        clone.style.marginTop = "-8px"
        const parent = wrapper.parentNode
        parent.appendChild(clone)
    }


    function createOnPaceFieldChanged(minsField, secsField, speedField) {
        return (function () {
            const min = minsField.value
            const sec = secsField.value

            const speed = paceToSpeed(`${min}:${sec}`)
            speedField.value = speed
        })
    }

    function createSpeedFieldChanged(minsField, secsField, speedField) {
        return (function () {
            try {
                let { min, sec } = speedToPace(speedField.value)
                if (min === 0 && sec === 0) {
                    sec = 1
                }

                if (min > 99) {
                    min = 99
                    sec = 59
                }

                minsField.value = min
                minsField.focus() // update internal component as if user typed value

                secsField.value = sec.toString().padStart(2, "0")
                secsField.focus()

                speedField.focus()
            } catch (e) {
                console.log(e)
            }
        })
    }

    function linkSpeedAndPaceField(speedField, paceId) {
        const paceFieldMins = document.getElementById(paceId)
        const paceFieldSecs = paceFieldMins.parentNode.querySelector("[class*=TimeDurationInput_seconds")

        const paceHandler = createOnPaceFieldChanged(paceFieldMins, paceFieldSecs, speedField)

        paceFieldMins.addEventListener("change", paceHandler)
        paceFieldMins.addEventListener("input", paceHandler)
        paceFieldMins.addEventListener("keydown", paceHandler)

        paceFieldSecs.addEventListener("change", paceHandler)
        paceFieldSecs.addEventListener("input", paceHandler)
        paceFieldSecs.addEventListener("keydown", paceHandler)

        paceHandler()

        const speedHandler = createSpeedFieldChanged(paceFieldMins, paceFieldSecs, speedField)
        speedField.addEventListener("change", speedHandler)
        // Can't update pace field immediately because the lower and upper range
        // are instantly validated against each other, so typing "1" for the upper speed (for example)
        // will instantly set a lower pace of "60:00" which will cause the upper pace to increase
        // to "60:00"
        //
        // speedField.addEventListener("input", speedHandler)
        // speedField.addEventListener("keydown", speedHandler)
    }

    // "5:00" => "12.00"
    function paceToSpeed(str) {
        const components = str.split(":")
        const min = parseInt(components[0], 10)
        const seconds = parseInt(components[1], 10)

        const secondsPerUnitDistance = min * 60 + seconds
        const unitDistancePerSecond = 1 / secondsPerUnitDistance
        const unitDistancePerHour = unitDistancePerSecond * 3600

        return (Math.round(unitDistancePerHour * 100) / 100).toFixed(2)
    }

    // "12.00" => { min: 5, sec: 0 }
    function speedToPace(str) {
        const speed = parseFloat(str)
        if (isNaN(speed)) {
            console.log("invalid speed " + str)
            throw Error("invalid speed")
        }

        const unitDistancePerSecond = speed / 3600
        const secondsPerUnitDistance = Math.round(1 / unitDistancePerSecond)

        const min = Math.floor(secondsPerUnitDistance / 60)
        const sec = secondsPerUnitDistance % 60
        return {
            min,
            sec,
        }
    }
})();