AO3: Reading Time & Quality Score

Combined reading time and quality scoring. Highly customizable.

// ==UserScript==
// @name        AO3: Reading Time & Quality Score
// @description Combined reading time and quality scoring. Highly customizable.
// @author      BlackBatCat
// @version     1.2
// @include     http://archiveofourown.org/*
// @include     https://archiveofourown.org/*
// @license     MIT
// @grant       none
// @namespace https://greasyfork.org/users/1498004
// ==/UserScript==

(function () {
  "use strict";

  // DEFAULT CONFIGURATION
  const DEFAULTS = {
    // Feature Toggles
    enableReadingTime: true,
    enableQualityScore: true,
    // Reading Time Settings
    wpm: 375,
    alwaysCountReadingTime: true,
    readingTimeLvl1: 120,
    readingTimeLvl2: 360,
    // Quality Score Settings
    alwaysCountQualityScore: true,
    alwaysSortQualityScore: false,
    hideHitcount: false,
    useNormalization: false,
    userMaxScore: 32,
    minKudosToShowScore: 100,
    colorThresholdLow: 10,
    colorThresholdHigh: 20,
    // Shared Color Settings
    colorGreen: "#3e8fb0",
    colorYellow: "#f6c177",
    colorRed: "#eb6f92",
    colorText: "#ffffff",
  };

  // Current config, loaded from localStorage
  let CONFIG = { ...DEFAULTS };

  // Variables to track the state of the page
  let countable = false;
  let sortable = false;
  let statsPage = false;

  // --- HELPER FUNCTIONS ---
  const $ = (selector, root = document) => root.querySelectorAll(selector);
  const $1 = (selector, root = document) => root.querySelector(selector);

  // Load user settings from localStorage
  const loadUserSettings = () => {
    if (typeof Storage === "undefined") return;
    for (const [key, defaultValue] of Object.entries(DEFAULTS)) {
      const saved = localStorage.getItem(key + "Local");
      if (saved !== null) {
        if (typeof defaultValue === "boolean") {
          CONFIG[key] = saved === "true";
        } else if (typeof defaultValue === "number") {
          CONFIG[key] = parseFloat(saved) || defaultValue;
        } else {
          CONFIG[key] = saved;
        }
      }
    }
  };

  // Save a setting to localStorage
  const saveSetting = (key, value) => {
    CONFIG[key] = value;
    if (typeof Storage !== "undefined") {
      localStorage.setItem(key + "Local", value);
    }
  };

  // Reset all settings to defaults
  const resetAllSettings = () => {
    if (confirm("Reset all settings to defaults?")) {
      for (const key of Object.keys(DEFAULTS)) {
        localStorage.removeItem(key + "Local");
      }
      CONFIG = { ...DEFAULTS };
      countRatio();
      calculateReadtime();
    }
  };

  // Robust number extraction from element
  const getNumberFromElement = (element) => {
    if (!element) return NaN;
    let text =
      element.getAttribute("data-ao3e-original") || element.textContent;
    if (text === null) return NaN;
    let cleanText = text.replace(/[,\s  ]/g, "");
    if (element.matches("dd.chapters")) {
      cleanText = cleanText.split("/")[0];
    }
    const number = parseInt(cleanText, 10);
    return isNaN(number) ? NaN : number;
  };

  // --- READING TIME FUNCTIONS ---
  const checkCountable = () => {
    const foundStats = $("dl.stats");
    if (foundStats.length === 0) return;

    const firstStat = foundStats[0];
    if (firstStat.closest("li")?.matches(".work, .bookmark")) {
      countable = sortable = true;
    } else if (firstStat.closest(".statistics")) {
      countable = sortable = statsPage = true;
    } else if (firstStat.closest("dl.work")) {
      countable = true;
    }
    // Menu logic is now handled by initSharedMenu()
  };

  const calculateReadtime = () => {
    if (!countable || !CONFIG.enableReadingTime) return;
    $("dl.stats").forEach((statsElement) => {
      // Check if readtime already exists to avoid duplicates
      if ($1("dt.readtime", statsElement)) return;
      const wordsElement = $1("dd.words", statsElement);
      if (!wordsElement) return;
      const words_count = getNumberFromElement(wordsElement);
      if (isNaN(words_count)) return;
      const minutes = words_count / CONFIG.wpm;
      const hrs = Math.floor(minutes / 60);
      const mins = (minutes % 60).toFixed(0);
      const minutes_print = hrs > 0 ? hrs + "h" + mins + "m" : mins + "m";
      // Create elements
      const readtime_label = document.createElement("dt");
      readtime_label.className = "readtime";
      readtime_label.textContent = "Readtime:";
      const readtime_value = document.createElement("dd");
      readtime_value.className = "readtime";
      readtime_value.textContent = minutes_print;
      // Apply styling
      readtime_value.style.color = CONFIG.colorText;
      readtime_value.style.borderRadius = "4px";
      readtime_value.style.padding = "0 6px";
      readtime_value.style.fontWeight = "bold";
      readtime_value.style.display = "inline-block";
      readtime_value.style.verticalAlign = "middle";
      // Apply color based on reading time
      if (minutes < CONFIG.readingTimeLvl1) {
        readtime_value.style.backgroundColor = CONFIG.colorGreen;
      } else if (minutes < CONFIG.readingTimeLvl2) {
        readtime_value.style.backgroundColor = CONFIG.colorYellow;
      } else {
        readtime_value.style.backgroundColor = CONFIG.colorRed;
      }
      // Inherit font size and line height from dl.stats
      const parentStats = readtime_value.closest("dl.stats");
      if (parentStats) {
        const computed = window.getComputedStyle(parentStats);
        readtime_value.style.lineHeight = computed.lineHeight;
        readtime_value.style.fontSize = computed.fontSize;
      }
      // Insert after words_value
      wordsElement.insertAdjacentElement("afterend", readtime_label);
      readtime_label.insertAdjacentElement("afterend", readtime_value);
    });
  };

  // --- QUALITY SCORE FUNCTIONS ---
  const calculateWordBasedScore = (kudos, hits, words) => {
    if (hits === 0 || words === 0 || kudos === 0) return 0;
    const effectiveChapters = words / 5000;
    const adjustedHits = hits / Math.sqrt(effectiveChapters);
    return (100 * kudos) / adjustedHits;
  };

  const countRatio = () => {
    if (!countable || !CONFIG.enableQualityScore) return;
    $("dl.stats").forEach((statsElement) => {
      // Check if score already exists to avoid duplicates
      if ($1("dt.kudoshits", statsElement)) return;
      const hitsElement = $1("dd.hits", statsElement);
      const kudosElement = $1("dd.kudos", statsElement);
      const wordsElement = $1("dd.words", statsElement);
      const parentLi = statsElement.closest("li");
      try {
        const hits = getNumberFromElement(hitsElement);
        const kudos = getNumberFromElement(kudosElement);
        const words = getNumberFromElement(wordsElement);
        if (isNaN(hits) || isNaN(kudos) || isNaN(words)) return;
        // Hide score if kudos below threshold
        if (kudos < CONFIG.minKudosToShowScore) {
          // Remove any previous score elements
          if (statsElement.querySelector("dt.kudoshits"))
            statsElement.querySelector("dt.kudoshits").remove();
          if (statsElement.querySelector("dd.kudoshits"))
            statsElement.querySelector("dd.kudoshits").remove();
          return;
        }
        let rawScore = calculateWordBasedScore(kudos, hits, words);
        if (kudos < 10) rawScore = 1;
        let displayScore = rawScore;
        // Normalize thresholds if normalization is enabled
        let thresholdLow = CONFIG.colorThresholdLow;
        let thresholdHigh = CONFIG.colorThresholdHigh;
        if (CONFIG.useNormalization) {
          displayScore = (rawScore / CONFIG.userMaxScore) * 100;
          displayScore = Math.min(100, displayScore);
          displayScore = Math.ceil(displayScore); // round up, no decimals
          thresholdLow = Math.ceil(
            (CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100
          );
          thresholdHigh = Math.ceil(
            (CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100
          );
        } else {
          displayScore = Math.round(displayScore * 10) / 10;
        }
        const ratioLabel = document.createElement("dt");
        ratioLabel.className = "kudoshits";
        ratioLabel.textContent = "Score:";
        const ratioValue = document.createElement("dd");
        ratioValue.className = "kudoshits";
        ratioValue.textContent = displayScore;
        ratioValue.style.color = CONFIG.colorText;
        ratioValue.style.borderRadius = "4px";
        ratioValue.style.padding = "0 6px";
        ratioValue.style.fontWeight = "bold";
        ratioValue.style.display = "inline-block";
        ratioValue.style.verticalAlign = "middle";
        // Apply color based on score
        if (displayScore >= thresholdHigh) {
          ratioValue.style.backgroundColor = CONFIG.colorGreen;
        } else if (displayScore >= thresholdLow) {
          ratioValue.style.backgroundColor = CONFIG.colorYellow;
        } else {
          ratioValue.style.backgroundColor = CONFIG.colorRed;
        }
        // Inherit font size and line height from dl.stats
        const parentStats = ratioValue.closest("dl.stats");
        if (parentStats) {
          const computed = window.getComputedStyle(parentStats);
          ratioValue.style.lineHeight = computed.lineHeight;
          ratioValue.style.fontSize = computed.fontSize;
        }
        hitsElement.insertAdjacentElement("afterend", ratioValue);
        hitsElement.insertAdjacentElement("afterend", ratioLabel);
        if (CONFIG.hideHitcount && !statsPage && hitsElement) {
          hitsElement.style.display = "none";
        }
        if (parentLi) parentLi.setAttribute("kudospercent", displayScore);
      } catch (error) {
        console.error("Error calculating score:", error);
      }
    });
  };

  const sortByRatio = (ascending = false) => {
    if (!sortable) return;
    $("dl.stats").forEach((statsElement) => {
      const parentLi = statsElement.closest("li");
      const list = parentLi?.parentElement;
      if (!list) return;
      const listElements = Array.from(list.children);
      listElements.sort((a, b) => {
        const aPercent = parseFloat(a.getAttribute("kudospercent")) || 0;
        const bPercent = parseFloat(b.getAttribute("kudospercent")) || 0;
        return ascending ? aPercent - bPercent : bPercent - aPercent;
      });
      list.innerHTML = "";
      list.append(...listElements);
    });
  };

  // --- SETTINGS POPUP ---
  const showSettingsPopup = () => {
    // Get AO3 input field background color
    let inputBg = "#fffaf5"; // fallback
    const testInput = document.createElement("input");
    document.body.appendChild(testInput);
    try {
      const computedBg = window.getComputedStyle(testInput).backgroundColor;
      if (
        computedBg &&
        computedBg !== "rgba(0, 0, 0, 0)" &&
        computedBg !== "transparent"
      ) {
        inputBg = computedBg;
      }
    } catch (e) {}
    testInput.remove();
    const popup = document.createElement("div");
    popup.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: ${inputBg};
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 20px rgba(0,0,0,0.2);
            z-index: 10000;
            width: 90%;
            max-width: 500px;
            max-height: 80vh;
            overflow-y: auto;
            font-family: inherit;
            font-size: 16px;
            box-sizing: border-box;
        `;
    const form = document.createElement("form");

    // Calculate values for display
    const displayThresholdLow = CONFIG.useNormalization
      ? Math.ceil((CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100)
      : CONFIG.colorThresholdLow;

    const displayThresholdHigh = CONFIG.useNormalization
      ? Math.ceil((CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100)
      : CONFIG.colorThresholdHigh;

    form.innerHTML = `
            <h3 style="margin-top: 0; text-align: center; font-size: 1.2em;">⚙️ Reading Time & Quality Score Settings ⚙️</h3>
            <hr style='margin: 16px 0; border: none; border-top: 1px solid #ccc;'>

            <div style="margin-bottom: 20px;">
                <h4 style="margin-bottom: 10px; font-size: 1.1em; font-weight: bold; display: flex; align-items: center;">
                    <span>Reading Time 📚</span>
                </h4>
                <label style="display: block; margin: 10px 0;">
                    <input type="checkbox" id="enableReadingTime" ${
                      CONFIG.enableReadingTime ? "checked" : ""
                    }>
                    Enable Reading Time
                </label>
                <div id="readingTimeSettings" style="margin-left: 20px; ${
                  CONFIG.enableReadingTime ? "" : "display: none;"
                }">
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" id="alwaysCountReadingTime" ${
                          CONFIG.alwaysCountReadingTime ? "checked" : ""
                        }>
                        Calculate automatically
                    </label>
                    <div style="margin: 10px 0;">
                        <label>Words per minute:</label>
                        <input type="number" id="wpm" value="${
                          CONFIG.wpm
                        }" min="100" max="1000" step="25">
                    </div>
                    <div style="margin: 10px 0;">
                        <label>Yellow threshold (minutes):</label>
                        <input type="number" id="readingTimeLvl1" value="${
                          CONFIG.readingTimeLvl1
                        }" min="5" max="240" step="5">
                    </div>
                    <div style="margin: 10px 0;">
                        <label>Red threshold (minutes):</label>
                        <input type="number" id="readingTimeLvl2" value="${
                          CONFIG.readingTimeLvl2
                        }" min="30" max="480" step="10">
                    </div>
                </div>
            </div>

            <div style="margin-bottom: 20px;">
                <h4 style="margin-bottom: 10px; font-size: 1.1em; font-weight: bold; display: flex; align-items: center;">
                    <span>Quality Score 💖</span>
                </h4>
                <label style="display: block; margin: 10px 0;">
                    <input type="checkbox" id="enableQualityScore" ${
                      CONFIG.enableQualityScore ? "checked" : ""
                    }>
                    Enable Quality Score
                </label>
                <div id="qualityScoreSettings" style="margin-left: 20px; ${
                  CONFIG.enableQualityScore ? "" : "display: none;"
                }">
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" id="alwaysCountQualityScore" ${
                          CONFIG.alwaysCountQualityScore ? "checked" : ""
                        }>
                        Calculate automatically
                    </label>
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" id="alwaysSortQualityScore" ${
                          CONFIG.alwaysSortQualityScore ? "checked" : ""
                        }>
                        Sort by score automatically
                    </label>
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" id="hideHitcount" ${
                          CONFIG.hideHitcount ? "checked" : ""
                        }>
                        Hide hit count
                    </label>
                    <div style="margin: 10px 0;">
                        <label>Minimum kudos to show score:</label>
                        <input type="number" id="minKudosToShowScore" value="${
                          CONFIG.minKudosToShowScore
                        }" min="0" max="10000" step="1">
                    </div>
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" id="useNormalization" ${
                          CONFIG.useNormalization ? "checked" : ""
                        }>
                        Normalize scores to 100%
                        <span title="Scales the raw score so your 'Best Possible Raw Score' equals 100%. Makes scores from different fandoms more comparable." style="margin-left: 4px; cursor: help;">❓</span>
                    </label>
                    <div id="userMaxScoreContainer" style="margin: 10px 0;${CONFIG.useNormalization ? '' : ' display:none;'}">
                        <label>Best Possible Raw Score <span id="normalizationLabel">${
                          CONFIG.useNormalization ? "(for 100%)" : ""
                        }</span>:</label>
                        <input type="number" id="userMaxScore" value="${
                          CONFIG.userMaxScore
                        }" min="1" max="100" step="1">
                    </div>
                    <div style="margin: 10px 0;">
                        <label>Good Score <span id="thresholdLowLabel">${
                          CONFIG.useNormalization ? "(%)" : ""
                        }</span>:</label>
                        <input type="number" id="colorThresholdLow" value="${displayThresholdLow}" min="0.1" max="100" step="0.1">
                    </div>
                    <div style="margin: 10px 0;">
                        <label>Excellent Score <span id="thresholdHighLabel">${
                          CONFIG.useNormalization ? "(%)" : ""
                        }</span>:</label>
                        <input type="number" id="colorThresholdHigh" value="${displayThresholdHigh}" min="0.1" max="100" step="0.1">
                    </div>
                </div>
            </div>

            <div style="margin-bottom: 20px;">
                <h4 style="margin-bottom: 10px; font-size: 1.1em; font-weight: bold; display: flex; align-items: center;">
                    <span>Color Settings 🎨</span>
                </h4>
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%;">
                    <div style="margin: 5px 0;">
                        <label style="display: block; margin-bottom: 5px;">Green:</label>
                        <input type="color" id="colorGreen" value="${
                          CONFIG.colorGreen
                        }" style="width: 100%;">
                    </div>
                    <div style="margin: 5px 0;">
                        <label style="display: block; margin-bottom: 5px;">Yellow:</label>
                        <input type="color" id="colorYellow" value="${
                          CONFIG.colorYellow
                        }" style="width: 100%;">
                    </div>
                    <div style="margin: 5px 0;">
                        <label style="display: block; margin-bottom: 5px;">Red:</label>
                        <input type="color" id="colorRed" value="${
                          CONFIG.colorRed
                        }" style="width: 100%;">
                    </div>
                    <div style="margin: 5px 0;">
                        <label style="display: block; margin-bottom: 5px;">Text color:</label>
                        <input type="color" id="colorText" value="${
                          CONFIG.colorText
                        }" style="width: 100%;">
                    </div>
                </div>
            </div>

            <div style="display: flex; justify-content: space-between; gap: 10px; margin-bottom: 5px;">
                <button type="submit" style="flex: 1; padding: 10px; font-size: 1em;">Save</button>
                <button type="button" id="closePopup" style="flex: 1; padding: 10px; font-size: 1em;">Close</button>
            </div>
            <div style="text-align: center; margin-top: 5px;">
                <a href="#" id="resetSettingsLink" style="font-size: 0.9em; color: #666; text-decoration: none;">Reset to Default Settings</a>
            </div>
        `;

    // Toggle reading time settings
    const readingTimeCheckbox = form.querySelector("#enableReadingTime");
    const readingTimeSettings = form.querySelector("#readingTimeSettings");
    const toggleReadingTimeSettings = () => {
      readingTimeSettings.style.display = readingTimeCheckbox.checked
        ? "block"
        : "none";
    };
    readingTimeCheckbox.addEventListener("change", toggleReadingTimeSettings);

    // Toggle quality score settings
    const qualityScoreCheckbox = form.querySelector("#enableQualityScore");
    const qualityScoreSettings = form.querySelector("#qualityScoreSettings");
    const toggleQualityScoreSettings = () => {
      qualityScoreSettings.style.display = qualityScoreCheckbox.checked
        ? "block"
        : "none";
    };
    qualityScoreCheckbox.addEventListener("change", toggleQualityScoreSettings);

    // Toggle normalization labels, convert values, and show/hide userMaxScore
    const normCheckbox = form.querySelector("#useNormalization");
    const normLabel = form.querySelector("#normalizationLabel");
    const thresholdLowLabel = form.querySelector("#thresholdLowLabel");
    const thresholdHighLabel = form.querySelector("#thresholdHighLabel");
    const thresholdLowInput = form.querySelector("#colorThresholdLow");
    const thresholdHighInput = form.querySelector("#colorThresholdHigh");
    const userMaxScoreInput = form.querySelector("#userMaxScore");
    const userMaxScoreContainer = form.querySelector("#userMaxScoreContainer");

    const toggleNormalization = () => {
      if (normCheckbox.checked) {
        normLabel.textContent = "(for 100%)";
        thresholdLowLabel.textContent = "(%)";
        thresholdHighLabel.textContent = "(%)";
        userMaxScoreContainer.style.display = "block";
        // Convert current raw thresholds to percentages
        thresholdLowInput.value = Math.ceil(
          (parseFloat(thresholdLowInput.value) /
            parseFloat(userMaxScoreInput.value)) *
            100
        );
        thresholdHighInput.value = Math.ceil(
          (parseFloat(thresholdHighInput.value) /
            parseFloat(userMaxScoreInput.value)) *
            100
        );
      } else {
        normLabel.textContent = "";
        thresholdLowLabel.textContent = "";
        thresholdHighLabel.textContent = "";
        userMaxScoreContainer.style.display = "none";
        // Convert current percentages back to raw values
        thresholdLowInput.value = Math.round(
          (parseFloat(thresholdLowInput.value) / 100) *
            parseFloat(userMaxScoreInput.value)
        );
        thresholdHighInput.value = Math.round(
          (parseFloat(thresholdHighInput.value) / 100) *
            parseFloat(userMaxScoreInput.value)
        );
      }
    };
    normCheckbox.addEventListener("change", toggleNormalization);

    // Add event listeners for reset and close
    form
      .querySelector("#resetSettingsLink")
      .addEventListener("click", function (e) {
        e.preventDefault();
        resetAllSettings();
        popup.remove();
      });
    form
      .querySelector("#closePopup")
      .addEventListener("click", () => popup.remove());

    // Form submission
    form.addEventListener("submit", (e) => {
      e.preventDefault();

      // Collect all values first
      let userMaxScoreValue = parseFloat(
        form.querySelector("#userMaxScore").value
      );
      let thresholdLowValue = parseFloat(
        form.querySelector("#colorThresholdLow").value
      );
      let thresholdHighValue = parseFloat(
        form.querySelector("#colorThresholdHigh").value
      );
      const isNormalizationEnabled =
        form.querySelector("#useNormalization").checked;

      // If normalization is enabled, convert percentages back to raw scores before saving
      if (isNormalizationEnabled) {
        thresholdLowValue = (thresholdLowValue / 100) * userMaxScoreValue;
        thresholdHighValue = (thresholdHighValue / 100) * userMaxScoreValue;
      }

      // Save all settings
      saveSetting(
        "enableReadingTime",
        form.querySelector("#enableReadingTime").checked
      );
      saveSetting(
        "enableQualityScore",
        form.querySelector("#enableQualityScore").checked
      );
      saveSetting(
        "alwaysCountReadingTime",
        form.querySelector("#alwaysCountReadingTime").checked
      );
      saveSetting("wpm", parseInt(form.querySelector("#wpm").value));
      saveSetting(
        "readingTimeLvl1",
        parseInt(form.querySelector("#readingTimeLvl1").value)
      );
      saveSetting(
        "readingTimeLvl2",
        parseInt(form.querySelector("#readingTimeLvl2").value)
      );
      saveSetting(
        "alwaysCountQualityScore",
        form.querySelector("#alwaysCountQualityScore").checked
      );
      saveSetting(
        "alwaysSortQualityScore",
        form.querySelector("#alwaysSortQualityScore").checked
      );
      saveSetting("hideHitcount", form.querySelector("#hideHitcount").checked);
      saveSetting(
        "minKudosToShowScore",
        parseInt(form.querySelector("#minKudosToShowScore").value)
      );
      saveSetting("useNormalization", isNormalizationEnabled);
      saveSetting("userMaxScore", userMaxScoreValue);
      // Save the potentially converted raw thresholds
      saveSetting("colorThresholdLow", thresholdLowValue);
      saveSetting("colorThresholdHigh", thresholdHighValue);
      saveSetting("colorGreen", form.querySelector("#colorGreen").value);
      saveSetting("colorYellow", form.querySelector("#colorYellow").value);
      saveSetting("colorRed", form.querySelector("#colorRed").value);
      saveSetting("colorText", form.querySelector("#colorText").value);

      popup.remove();
      countRatio();
      calculateReadtime();
    });

    popup.appendChild(form);
    document.body.appendChild(popup);
  };

  // --- UI MENU ---
  // Helper: check if current page is one of the allowed types for menu options
  function isAllowedMenuPage() {
    const path = window.location.pathname;
    // User bookmarks: /users/USERNAME/bookmarks or /bookmarks
    if (/^\/users\/[^\/]+\/bookmarks(\/|$)/.test(path) || /^\/bookmarks(\/|$)/.test(path)) return true;
    // User profile: /users/USERNAME (no trailing /works etc)
    if (/^\/users\/[^\/]+\/?$/.test(path)) return true;
    // Tag works: /tags/ANYTHING/works
    if (/^\/tags\/[^\/]+\/works(\/|$)/.test(path)) return true;
    // Collections: /collections/ANYTHING
    if (/^\/collections\/[^\/]+(\/|$)/.test(path)) return true;
    // Works index: /works
    if (/^\/works(\/|$)/.test(path)) return true;
    return false;
  }

  // --- SHARED MENU MANAGEMENT ---
  function initSharedMenu() {
    // Create shared menu object if it doesn't exist (copied from ao3_chapter_shortcuts.js)
    if (!window.AO3UserScriptMenu) {
      window.AO3UserScriptMenu = {
        items: [],
        register: function(item) {
          this.items.push(item);
          this.renderMenu();
        },
        renderMenu: function() {
          // Find or create menu container
          let menuContainer = document.getElementById('ao3-userscript-menu');
          if (!menuContainer) {
            const headerMenu = document.querySelector("ul.primary.navigation.actions");
            const searchItem = headerMenu ? headerMenu.querySelector("li.search") : null;
            if (!headerMenu || !searchItem) return;
            menuContainer = document.createElement("li");
            menuContainer.className = "dropdown";
            menuContainer.id = "ao3-userscript-menu";
            const title = document.createElement("a");
            title.href = "#";
            title.textContent = "Userscripts";
            menuContainer.appendChild(title);
            const menu = document.createElement("ul");
            menu.className = "menu dropdown-menu";
            menuContainer.appendChild(menu);
            headerMenu.insertBefore(menuContainer, searchItem);
          }
          // Render menu items
          const menu = menuContainer.querySelector("ul.menu");
          if (menu) {
            menu.innerHTML = "";
            this.items.forEach(item => {
              const li = document.createElement("li");
              const a = document.createElement("a");
              a.href = "#";
              a.textContent = item.label;
              a.addEventListener("click", (e) => {
                e.preventDefault();
                item.onClick();
              });
              li.appendChild(a);
              menu.appendChild(li);
            });
          }
        }
      };
    }

    // Register this script's menu items
    const showMenuOptions = isAllowedMenuPage();
    if (showMenuOptions && CONFIG.enableReadingTime) {
      window.AO3UserScriptMenu.register({
        label: "Reading Time: Calculate",
        onClick: calculateReadtime,
      });
    }
    if (showMenuOptions && CONFIG.enableQualityScore) {
      window.AO3UserScriptMenu.register({
        label: "Quality Score: Calculate Scores",
        onClick: countRatio,
      });
      window.AO3UserScriptMenu.register({
        label: "Quality Score: Sort by Score",
        onClick: () => sortByRatio(),
      });
    }
    window.AO3UserScriptMenu.register({
      label: "Reading Time & Quality Score Settings",
      onClick: showSettingsPopup,
    });
  }

  // --- INITIALIZATION ---
  loadUserSettings();
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () => {
      checkCountable();
      initSharedMenu();
      if (CONFIG.alwaysCountReadingTime) setTimeout(calculateReadtime, 1000);
      if (CONFIG.alwaysCountQualityScore) setTimeout(countRatio, 1000);
    });
  } else {
    checkCountable();
    initSharedMenu();
    if (CONFIG.alwaysCountReadingTime) setTimeout(calculateReadtime, 1000);
    if (CONFIG.alwaysCountQualityScore) setTimeout(countRatio, 1000);
  }
})();