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-26 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.2
// ==/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;
    color: black !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