Better Segments for Strava

A userscript for Strava that adds additional stats and features to the starred segments page.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Better Segments for Strava
// @namespace    https://github.com/twocaretcat
// @version      0.2.0
// @author       John Goodliff
// @description  A userscript for Strava that adds additional stats and features to the starred segments page.
// @license      MIT
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA51BMVEUAAAD8TAL8TAL8TAIAAAD8TAL39/fg4OD8TAIAAAAAAAD8TAL8TAIAAAD8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TALl5eX8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL5+fn4+Pj8TAL8TAL8TALWQAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL///8AuADjRAKq2Kqk1qTM58zKysqnpqZ8fHz39/ft7e3n5+fi8eLV1dXFxcWxsbHj4eHX19eampqZmZlUgKVHAAAAOHRSTlMAZJ6ZBwmnhfYYDOAeGigL8+LXsD8t+NzRq6iHfldKOv3vyr+5kHRvXkQwEaSkjYF4cRa4lFMgHLx0l9gAAAHDSURBVFjD7ZVnW8IwEIBRrBaK0kLZG9koAq6LqOzh+P+/x3BEbEkHtvLFp+8XknD3JiSX4PM4PBcxd/lCJBF2JUgDRN3k14FSc55fiAOlWHCa35YAUfoOBRVgXDrLv4EtGbtYVeDHmqAhb3DCqqbjj3D1EiphZjSFH3KIq7DksVYAYnfHX8ZEKSwksFHeWWNNBL2A7pRukiqmiS06lYhNv255FQBOAHLuZyAHSGOnzbgvASdAUm3s4ayakLvNar63KZwGMBGA0mRXCHtJQbcf7FqpEpgIkM46aTOF/LB7ImtZBsBSAPRA69zZ5wGp0cMDOwGI1TirPq4qix0RLAQKfmtS/xXQEJcMBccxzfqUR72gL2l+ZctvLNDsUFE1fBuQquAzE9AbxCbqGr1OiEL31kKAVWLyDqZYrVkIkJwMkuFLLCRAbvDx/EAo2jL5j7gN+awF9ngCT3BgwS/xBLygcOQA1fePCF6dItdBh/EBMn+lzEnvaC96hEymkykhga3g+YnyQgawFwMyWa4Wn1Ny7liwWC0/3kcuBGQ4ehsT5wKaPxzPxu5WMNOtAGEDtvDxwewZkrWrA9P4E4ZdJhfv8Td8Aar4+B4sHPOcAAAAAElFTkSuQmCC
// @homepage     https://johng.io/p/better-segments-for-strava
// @homepageURL  https://johng.io/p/better-segments-for-strava
// @source       https://github.com/twocaretcat/better-segments-for-strava.git
// @supportURL   https://github.com/twocaretcat/better-segments-for-strava/issues
// @match        https://www.strava.com/athlete/segments/starred*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const PROJECT = {
    EMOJI: "🏁",
    NAME: "Better Segments"
  };
  const SCRIPT_NAME = `${PROJECT.EMOJI} ${PROJECT.NAME}`;
  const buildLogPrefix = /* @__PURE__ */ (() => {
    const colorMap = {
      primary: "#fc5200",
      // Strava orange
      debug: "#009966",
      // TW 4 Emerald 600
      info: "#4f39f6",
      // TW 4 Indigo 600
      warn: "#fc5200",
      // Strava orange
      error: "#e7000b"
      // TW 4 Red 600
    };
    return (severity) => [
      `%c${SCRIPT_NAME} %c${severity}`,
      `font-style:italic;color:${colorMap.primary};`,
      `color:${colorMap[severity]};`
    ];
  })();
  const buildLogFn = (severity) => {
    const logFn = console[severity];
    const logPrefix = buildLogPrefix(severity);
    return (...args) => logFn(...logPrefix, ...args);
  };
  const debug = buildLogFn("debug");
  buildLogFn("info");
  buildLogFn("warn");
  buildLogFn("error");
  const pattern = /^\/athlete\/segments\/starred$/;
  const handler = () => {
    debug("Setting up starred segments page");
    const MSG = {
      rowNum: "#",
      recordDiffSeconds: "PR Diff Time",
      recordDiffPercent: "PR Diff %",
      gender: "Compare with Gender",
      itemsPerPage: "Items per Page",
      genderAny: "Any",
      genderMen: "Men",
      genderWomen: "Women",
      kilometersSuffix: "km",
      milesSuffix: "mi",
      feetSuffix: "ft",
      percentSuffix: "%",
      secondsSuffix: "s",
      plusPrefix: "+",
      minusPrefix: "-",
      placeholderValue: "—"
    };
    const SORT_INDICATOR = {
      1: " ▲",
      "-1": " ▼"
    };
    const ITEMS_PER_PAGE = [20, 50, 100, 200, 500, 1e3, 2e3];
    const GENDER = {
      any: MSG.genderAny,
      men: MSG.genderMen,
      women: MSG.genderWomen
    };
    const QUERY_PARAM = {
      itemsPerPage: "per_page",
      pageNum: "page"
    };
    const COL_INDEX = {
      rowNum: 0,
      star: 1,
      sport: 2,
      name: 3,
      category: 4,
      distance: 5,
      elevationDiff: 6,
      avgGrade: 7,
      menRecord: 8,
      womenRecord: 9,
      myRecord: 10,
      myGoal: 11,
      recordDiffSeconds: 12,
      recordDiffPercentage: 13
    };
    const COLS = [
      {
        parseFn: Number.parseInt,
        title: MSG.rowNum
      },
      {},
      {
        parseFn: parseText
      },
      {
        parseFn: parseText
      },
      {},
      {
        parseFn: parseDistance
      },
      {
        parseFn: parseDistance
      },
      {
        parseFn: parsePercentage
      },
      {
        parseFn: parseDuration
      },
      {
        parseFn: parseDuration
      },
      {
        parseFn: parseDuration
      },
      {
        parseFn: parseDuration
      },
      {
        parseFn: parseDuration,
        title: MSG.recordDiffSeconds
      },
      {
        parseFn: parsePercentage,
        title: MSG.recordDiffPercent
      }
    ];
    const DEFAULT = {
      itemsPerPage: ITEMS_PER_PAGE[0],
      gender: GENDER.any,
      sortColIndex: COL_INDEX.rowNum,
      sortDirection: 1
    };
    const sortIndicatorRegex = RegExp(
      ` [${Object.values(SORT_INDICATOR).join("")}]$`
    );
    const state = {
      gender: DEFAULT.gender,
      sort: { colIndex: DEFAULT.sortColIndex, direction: DEFAULT.sortDirection }
    };
    const [table, headerRow, rows] = buildTable();
    updateTable();
    sortTableByCol(0);
    function assertExists(value, msg) {
      if (value === null || value === void 0) throw new Error(msg);
      return value;
    }
    function formatTimeDiff(secondsDiff) {
      const absSecondsDiff = Math.abs(secondsDiff);
      if (absSecondsDiff < 60) return `${absSecondsDiff}${MSG.secondsSuffix}`;
      const minutes = Math.floor(absSecondsDiff / 60);
      const secondsString = String(absSecondsDiff % 60).padStart(2, "0");
      return `${minutes}:${secondsString}`;
    }
    function getSignFor(number) {
      return number < 0 ? MSG.minusPrefix : number > 0 ? MSG.plusPrefix : "";
    }
    function getQueryParam(key) {
      return new URLSearchParams(window.location.search).get(key);
    }
    function getColorFor(value) {
      const lightness = 0.7;
      const chroma = 0.15;
      const hue = 150 - Math.sign(value) * Math.abs(value) ** (1 / 5) * 30;
      return `oklch(${lightness} ${chroma} ${hue})`;
    }
    function parseDuration(durationString) {
      if (durationString === MSG.placeholderValue) return null;
      if (durationString.includes(":")) {
        const [min, sec] = durationString.split(":").map(Number);
        return min * 60 + sec;
      }
      if (durationString.endsWith("s")) {
        return Number.parseInt(durationString);
      }
      return Number.parseFloat(durationString);
    }
    function parsePercentage(percentageString) {
      if (percentageString === MSG.placeholderValue) return null;
      return Number.parseFloat(percentageString);
    }
    function parseDistance(distanceString) {
      const distanceValue = Number.parseFloat(distanceString);
      if (distanceString.endsWith(MSG.kilometersSuffix))
        return distanceValue * 1e3;
      if (distanceString.endsWith(MSG.milesSuffix))
        return distanceValue * 1609.344;
      if (distanceString.endsWith(MSG.feetSuffix)) return distanceValue * 0.3048;
      return distanceValue;
    }
    function parseText(textString) {
      return textString.toLowerCase();
    }
    function createCol(colIndex) {
      const colHeader = document.createElement("th");
      colHeader.textContent = COLS[colIndex].title;
      colHeader.style.cursor = "pointer";
      return colHeader;
    }
    function createSelector(title, optionNames, defaultOptionName, onChange) {
      const fragment = document.createDocumentFragment();
      const wrapperDiv = document.createElement("div");
      wrapperDiv.className = "edit-js editable-setting editing inset";
      wrapperDiv.style.background = "#f7f7fa";
      wrapperDiv.style.borderTop = "1px solid #f0f0f5";
      wrapperDiv.style.borderBottom = "1px solid #f0f0f5";
      wrapperDiv.style.marginTop = "20px";
      wrapperDiv.style.marginBottom = "-1px";
      const form = document.createElement("form");
      form.noValidate = true;
      const labelDiv = document.createElement("div");
      labelDiv.className = "setting-label";
      labelDiv.textContent = title;
      labelDiv.style.color = "#494950";
      labelDiv.style.position = "relative";
      const valueDiv = document.createElement("div");
      valueDiv.className = "setting-value";
      valueDiv.style.marginTop = "6px";
      const select = document.createElement("select");
      select.className = "valid";
      for (const optionName of optionNames) {
        const option = document.createElement("option");
        option.value = optionName;
        option.textContent = optionName;
        option.selected = optionName === defaultOptionName;
        select.appendChild(option);
      }
      select.addEventListener("change", onChange);
      valueDiv.appendChild(select);
      form.appendChild(labelDiv);
      form.appendChild(valueDiv);
      wrapperDiv.appendChild(form);
      fragment.appendChild(wrapperDiv);
      return fragment;
    }
    function buildTableOptions(table2) {
      const tableParent = assertExists(
        table2.parentElement,
        "Table parent not found."
      );
      const currentItemsPerPage = Number(getQueryParam(QUERY_PARAM.itemsPerPage)) ?? DEFAULT.itemsPerPage;
      const itemsPerPageSelector = createSelector(
        MSG.itemsPerPage,
        ITEMS_PER_PAGE,
        currentItemsPerPage,
        ({ target }) => {
          const url = new URL(window.location.href);
          url.searchParams.set(QUERY_PARAM.itemsPerPage, target.value);
          window.location.href = url.toString();
        }
      );
      const genderSelector = createSelector(
        MSG.gender,
        Object.values(GENDER),
        DEFAULT.gender,
        ({ target }) => {
          state.gender = target.value;
          updateTable();
          sortTableByCol();
        }
      );
      tableParent.insertBefore(itemsPerPageSelector, table2);
      tableParent.insertBefore(genderSelector, table2);
    }
    function buildTableHeaders(headerRow2) {
      const rowNumCol = createCol(COL_INDEX.rowNum);
      const recordDiffSecondsCol = createCol(COL_INDEX.recordDiffSeconds);
      const recordDiffPercentageCol = createCol(COL_INDEX.recordDiffPercentage);
      headerRow2.insertBefore(rowNumCol, headerRow2.firstChild);
      headerRow2.appendChild(recordDiffSecondsCol);
      headerRow2.appendChild(recordDiffPercentageCol);
      [...headerRow2.children].forEach((th, i) => {
        th.style.cursor = "pointer";
        th.addEventListener("click", () => sortTableByCol(i, true));
      });
    }
    function buildTableCols(rows2) {
      const pageNum = Number(getQueryParam(QUERY_PARAM.pageNum) ?? 1);
      const startIndex = (pageNum - 1) * DEFAULT.itemsPerPage + 1;
      rows2.forEach((row, i) => {
        const indexCell = row.insertCell(0);
        indexCell.textContent = startIndex + i;
        row.insertCell();
        row.insertCell();
      });
    }
    function buildTable() {
      const table2 = assertExists(
        document.querySelector("table.starred-segments"),
        "Table not found."
      );
      const headerRow2 = assertExists(
        table2.querySelector("thead tr"),
        "Table header row not found."
      );
      const rows2 = [...table2.querySelectorAll("tbody tr")];
      buildTableOptions(table2);
      buildTableHeaders(headerRow2);
      buildTableCols(rows2);
      return [table2, headerRow2, rows2];
    }
    function updateTable() {
      const gender = state.gender;
      for (const row of rows) {
        const cells = row.children;
        const [menRecordSeconds, womenRecordSeconds, myRecordSeconds] = [
          "menRecord",
          "womenRecord",
          "myRecord"
        ].map((key) => parseDuration(cells[COL_INDEX[key]].textContent.trim()));
        let recordSeconds = menRecordSeconds;
        if (gender === GENDER.women) {
          recordSeconds = womenRecordSeconds;
        } else if (gender === GENDER.any) {
          recordSeconds = Math.min(
            menRecordSeconds ?? Number.POSITIVE_INFINITY,
            womenRecordSeconds ?? Number.POSITIVE_INFINITY
          );
        }
        const recordDiffSecondsCell = cells[COL_INDEX.recordDiffSeconds];
        const recordDiffPercentageCell = cells[COL_INDEX.recordDiffPercentage];
        if (recordSeconds != null && myRecordSeconds != null) {
          const secondsDiff = myRecordSeconds - recordSeconds;
          const percentDiff = secondsDiff / recordSeconds * 100;
          const color = getColorFor(percentDiff);
          recordDiffSecondsCell.textContent = `${getSignFor(secondsDiff)}${formatTimeDiff(secondsDiff)}`;
          recordDiffPercentageCell.textContent = `${getSignFor(percentDiff)}${Math.abs(percentDiff).toFixed(0)}${MSG.percentSuffix}`;
          recordDiffSecondsCell.style.color = color;
          recordDiffPercentageCell.style.color = color;
        } else {
          recordDiffSecondsCell.textContent = MSG.placeholderValue;
          recordDiffPercentageCell.textContent = MSG.placeholderValue;
          recordDiffSecondsCell.style.color = "";
          recordDiffPercentageCell.style.color = "";
        }
      }
    }
    function sortTableByCol(colIndex = state.sort.colIndex, toggleDirection = false) {
      const { parseFn } = COLS[colIndex];
      if (!parseFn) {
        console.warn(`No parse function defined for column ${colIndex}`);
        return;
      }
      const direction = state.sort.direction * (toggleDirection ? -1 : 1);
      state.sort.colIndex = colIndex;
      state.sort.direction = direction;
      rows.sort((rowA, rowB) => {
        const [valA, valB] = [rowA, rowB].map(
          (row) => parseFn(row.children[colIndex].textContent.trim()) ?? Number.POSITIVE_INFINITY
        );
        if (typeof valA === "number" && typeof valB === "number") {
          return (valA - valB) * direction;
        }
        return String(valA).localeCompare(String(valB)) * direction;
      });
      [...headerRow.children].forEach((th, i) => {
        th.innerHTML = th.innerHTML.replace(sortIndicatorRegex, "");
        if (i === colIndex) {
          th.innerHTML += SORT_INDICATOR[direction];
        }
      });
      const tbody = table.querySelector("tbody");
      for (const row of rows) {
        tbody.appendChild(row);
      }
    }
  };
  const starredSegmentsRoute = {
    pattern,
    handler
  };
  const registerRouteHandlers = (routes) => {
    const path = location.pathname;
    for (const { pattern: pattern2, handler: handler2 } of routes) {
      if (pattern2.test(path)) {
        handler2();
        break;
      }
    }
  };
  const init = () => {
    debug("Script loaded");
    registerRouteHandlers([starredSegmentsRoute]);
    debug("Script unloaded");
  };
  init();

})();