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

目前為 2025-09-27 提交的版本,檢視 最新版本

// ==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.3
// ==/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
        if (innerText.includes("/km") || innerText.includes("/mi")) {
            if (innerText.includes("/mi")) {
                metric = false
                innerText = replaceAll(innerText, "mi", "km")
            }
            // const components = innerText.split('\n')

            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"
            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)

        // let cloneHTML = clone.innerHTML
        // cloneHTML = replaceAll(cloneHTML, "min/km", "km/h")
        // cloneHTML = replaceAll(cloneHTML, "min/mi", "mph")
        // clone.innerHTML = cloneHTML

        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.classList.add("_speed-mod-label")
            unitsLabel.innerText = metric ? "km/h" : "mph"
        }

        let minsFields = clone.querySelectorAll("[class*=TimeDurationInput_minutes]")
        for (const field of minsFields) {
            // field.parentNode.innerText = ""
            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.parentNode.className = ""
            field.setAttribute("placeholder", "")

            field.setAttribute("style",
`
    // background-color: inherit !important;
    caret-color: inherit !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())
        }

        clone.style.marginTop = "-7.5px"
        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
                }

                minsField.value = min
                // minsField.dispatchEvent(new Event("focus"));
                minsField.focus()

                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)
        // 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)
    }

    // "5:00" <= "12.00"
    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,
        }
    }

})();

/*
5:00 min/km
300 seconds/km
0.00333333333333333333 km /second
~12 kph
*/

// #target-pace-input
//.TimeDurationInput_seconds__n9cXY

//#target-pace-to
//.TimeDurationInput_seconds__n9cXY

// ancestor: RangeInput_content__S6u45

// .WorkoutStep_stepEditOpenContent__GN0zc
// .WorkoutStep_doneButton__xJZOm > button