ReadTheory Average Grade Level Calculator (Past 10 Quizzes)

Calculates the average of the most recent 10 quizzes on ReadTheory.org

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ReadTheory Average Grade Level Calculator (Past 10 Quizzes)
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      1.16
// @description  Calculates the average of the most recent 10 quizzes on ReadTheory.org
// @author       Stuart Saddler
// @icon         https://images-na.ssl-images-amazon.com/images/I/41Y-bktG5oL.png
// @license      MIT
// @match        *://readtheory.org/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  "use strict";

  // -------- SPA routing hook (document-start) --------
  // Monkeypatch pushState/replaceState so we can detect client-side route changes.
  const _pushState = history.pushState;
  const _replaceState = history.replaceState;

  function emitLocationChange(detail) {
    window.dispatchEvent(new CustomEvent("locationchange", { detail }));
  }

  history.pushState = function () {
    const ret = _pushState.apply(this, arguments);
    emitLocationChange({ type: "pushState" });
    return ret;
  };

  history.replaceState = function () {
    const ret = _replaceState.apply(this, arguments);
    emitLocationChange({ type: "replaceState" });
    return ret;
  };

  window.addEventListener("popstate", function () {
    emitLocationChange({ type: "popstate" });
  });

  // -------- Config and state --------
  const NUM_RECENT = 10;
  const DEBUG = true;

  let booted = false;                // global guard so we do not double-initialize
  let destroying = false;            // to avoid races during teardown
  let observers = [];                // track observers to disconnect on teardown
  let lastCalculatedData = null;
  let isProcessing = false;
  let hasSuccessfulResult = false;
  let cachedTable = null;
  let cacheTimestamp = 0;
  const CACHE_DURATION = 10000; // 10 seconds
  let changeTimeout = null;
  let locationPoller = null;

  function log() {
    if (!DEBUG) return;
    const args = Array.from(arguments);
    args.unshift("[ReadTheory Average]");
    console.log.apply(console, args);
  }

  // -------- Helpers to start/stop the feature on correct routes --------
  function onReportsRoute() {
    // Run on any teacher reports routes, including student detail pages.
    // Example: /app/teacher/reports/... or /app/teacher/reports/student/...
    const p = location.pathname;
    return p.startsWith("/app/teacher/reports/");
  }

  function debounce(fn, delay) {
    let t;
    return function () {
      clearTimeout(t);
      const self = this;
      const args = arguments;
      t = setTimeout(function () {
        fn.apply(self, args);
      }, delay);
    };
  }

  // -------- Main feature lifecycle --------
  async function boot() {
    if (booted || destroying) return;
    if (!onReportsRoute()) return;

    booted = true;
    lastCalculatedData = null;
    isProcessing = false;
    hasSuccessfulResult = false;
    cachedTable = null;
    cacheTimestamp = 0;

    // Make a retry function for the floating button.
    window.retryCalculation = async () => {
      log("Manual retry triggered");
      hasSuccessfulResult = false;
      cachedTable = null;
      cacheTimestamp = 0;
      await calculateAndDisplayAverage(true);
    };

    // A short wait gives Vue time to mount initial shells.
    await wait(600);

    // Start watchers that should be active only on the reports pages.
    setupVueReactivityObserver();
    setupEnhancedChangeDetection();

    // Safety polling in case the app updates without classic events firing.
    startLocationPoller();

    // Try initial run. We also do a small stagger to let the "Quiz History" panel render.
    await wait(800);
    await calculateAndDisplayAverage(true);

    log("Initialized on reports route");
  }

  function teardown() {
    if (!booted) return;
    destroying = true;
    try {
      // Disconnect observers
      observers.forEach(o => {
        try { o.disconnect(); } catch (e) {}
      });
      observers = [];

      // Clear timers
      if (changeTimeout) {
        clearTimeout(changeTimeout);
        changeTimeout = null;
      }
      if (locationPoller) {
        clearInterval(locationPoller);
        locationPoller = null;
      }

      // Remove floating window to avoid stale state if we leave the route
      const el = document.getElementById("average-grade-floating-window");
      if (el && el.parentNode) el.parentNode.removeChild(el);

      // Reset state
      lastCalculatedData = null;
      isProcessing = false;
      hasSuccessfulResult = false;
      cachedTable = null;
      cacheTimestamp = 0;
    } finally {
      booted = false;
      destroying = false;
      log("Teardown complete");
    }
  }

  // Re-evaluate on SPA route changes
  const onLocationChange = debounce(() => {
    log("locationchange detected:", location.pathname + location.search);
    if (onReportsRoute()) {
      if (!booted) boot();
      else {
        // Same feature, new sub-route or params
        hasSuccessfulResult = false;
        cachedTable = null;
        cacheTimestamp = 0;
        debounceCalculation(1200);
      }
    } else {
      teardown();
    }
  }, 150);

  window.addEventListener("locationchange", onLocationChange);

  // Also run once the DOM is ready. We cannot run heavy DOM work at document-start.
  function onReady(fn) {
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", fn, { once: true });
    } else {
      fn();
    }
  }

  onReady(() => {
    // Initial decision based on current route
    if (onReportsRoute()) boot();
    // As a safety net, also check when the tab becomes visible again
    document.addEventListener("visibilitychange", () => {
      if (!document.hidden) onLocationChange();
    });
  });

  // -------- Utility waits --------
  function wait(ms) {
    return new Promise(r => setTimeout(r, ms));
  }

  // -------- Your original logic (lightly organized) --------
  function getCachedTable() {
    const now = Date.now();
    if (cachedTable && (now - cacheTimestamp) < CACHE_DURATION) {
      if (document.body.contains(cachedTable)) {
        return cachedTable;
      }
    }
    cachedTable = findQuizHistoryTable();
    cacheTimestamp = now;
    return cachedTable;
  }

  async function waitForQuizTable(maxAttempts = 12, baseWaitTime = 600) {
    for (let i = 0; i < maxAttempts; i++) {
      log("Attempt", i + 1, "/", maxAttempts, "looking for quiz table...");
      const table = findQuizHistoryTable();
      if (table) {
        const dataRows = getTopRowsByNumberCell(table, 1);
        if (dataRows.length > 0) {
          cachedTable = table;
          cacheTimestamp = Date.now();
          return table;
        }
      }
      const currentWaitTime = Math.min(baseWaitTime + i * 200, 1600);
      await wait(currentWaitTime);
    }
    return await fallbackTableDetection();
  }

  async function fallbackTableDetection() {
    const tables = document.querySelectorAll("table");
    for (const table of tables) {
      const hasNumericData = Array.from(table.querySelectorAll("td")).some(td =>
        /^\d+$/.test(td.textContent.trim())
      );
      const hasGradeData = Array.from(table.querySelectorAll("td")).some(td =>
        /^(one|two|three|four|five|six|seven|eight|nine|ten|\d+\.?\d*)$/i.test(td.textContent.trim())
      );
      if (hasNumericData && hasGradeData) {
        log("Fallback table detection successful");
        return table;
      }
    }
    return null;
  }

  function findQuizHistoryTable() {
    const strategies = [
      () => document.querySelector("table.hi-table.desktop-only"),
      () => {
        const tables = Array.from(document.querySelectorAll("table"));
        return tables.find(t => t.querySelector("td.number-cell"));
      },
      () => {
        const quizPanel = document.querySelector(".quiz-history-panel");
        return quizPanel ? quizPanel.querySelector("table") : null;
      },
      () => {
        const vueTables = document.querySelectorAll("table[data-v-07edfc42], table[class*='hi-table']");
        return vueTables.length > 0 ? vueTables[0] : null;
      },
      () => {
        const tables = Array.from(document.querySelectorAll("table"));
        return tables.find(table => {
          const hasHeaders = table.querySelector("thead th");
          const hasRows = table.querySelectorAll("tbody tr").length > 0;
          return hasHeaders && hasRows;
        });
      }
    ];
    for (let i = 0; i < strategies.length; i++) {
      const result = strategies[i]();
      if (result) {
        log("Found table using strategy", i + 1);
        return result;
      }
    }
    return null;
  }

  function setupVueReactivityObserver() {
    const observer = new MutationObserver((mutations) => {
      let shouldRecalc = false;
      for (const mutation of mutations) {
        if (mutation.type === "attributes" && mutation.attributeName && mutation.attributeName.startsWith("data-v-")) {
          shouldRecalc = true;
        }
        if (mutation.target.tagName === "TABLE" ||
            (mutation.target.closest && mutation.target.closest("table"))) {
          shouldRecalc = true;
          cachedTable = null;
          cacheTimestamp = 0;
        }
        if (mutation.target.classList &&
            (mutation.target.classList.contains("activity-tab") ||
             mutation.target.classList.contains("active"))) {
          shouldRecalc = true;
        }
      }
      if (shouldRecalc && !isProcessing) {
        log("Vue reactivity change detected");
        debounceCalculation(600);
      }
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["data-v-07edfc42", "class", "aria-selected"]
    });
    observers.push(observer);
  }

  function setupEnhancedChangeDetection() {
    const checkForChanges = () => {
      if (isProcessing) return;
      debounceCalculation(800);
    };
    window.addEventListener("hashchange", checkForChanges);
    document.addEventListener("click", (e) => {
      if (e.target && typeof e.target.closest === "function") {
        if (e.target.closest("[class*='student'], [class*='selector'], select, .activity-tab, a, button")) {
          debounceCalculation(800);
        }
      }
    });
    document.addEventListener("change", (event) => {
      if (event.target.tagName === "SELECT" && !isProcessing) {
        updateDisplay("Average Grade Level: Student changed, loading new data...");
        hasSuccessfulResult = false;
        cachedTable = null;
        cacheTimestamp = 0;
        debounceCalculation(900);
      }
    });
  }

  function startLocationPoller() {
    // Safety net for any framework updates that do not trigger our hooks
    if (locationPoller) return;
    let last = location.pathname + location.search + location.hash;
    locationPoller = setInterval(() => {
      const cur = location.pathname + location.search + location.hash;
      if (cur !== last) {
        last = cur;
        emitLocationChange({ type: "poll" });
      }
    }, 1000);
  }

  function gradeTextToNumber(gradeText) {
    if (!gradeText) return 0;
    const txt = String(gradeText).toLowerCase().trim();
    const map = {
      "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
      "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
      "thirteen": 13, "fourteen": 14, "fifteen": 15
    };
    if (map[txt] != null) return map[txt];
    const num = parseFloat(txt.replace(/[^0-9.]/g, ""));
    return Number.isFinite(num) ? num : 0;
  }

  function calculateAverage(nums) {
    const valid = nums.filter(n => Number.isFinite(n) && n > 0);
    if (valid.length === 0) return "0.0";
    const total = valid.reduce((a, b) => a + b, 0);
    return (total / valid.length).toFixed(1);
  }

  function findGradeColumnIndex(table) {
    const firstGradeTd = table.querySelector("tbody tr td.grade-level-cell");
    if (firstGradeTd) {
      const all = Array.from(firstGradeTd.parentElement.querySelectorAll("td"));
      const index = all.indexOf(firstGradeTd);
      log("Found grade column at index", index, "using grade-level-cell class");
      return index;
    }
    const ths = Array.from(table.querySelectorAll("thead th, tr:first-child th"));
    for (let i = 0; i < ths.length; i++) {
      const headerText = (ths[i].textContent || "").toLowerCase();
      if (headerText.includes("grade") && headerText.includes("level")) {
        log("Found grade column at index", i, "using header text");
        return i;
      }
    }
    const rows = Array.from(table.querySelectorAll("tbody tr")).slice(0, 5);
    const gradeWords = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"];
    for (const row of rows) {
      const cells = Array.from(row.querySelectorAll("td"));
      for (let i = 0; i < cells.length; i++) {
        const text = cells[i].textContent.toLowerCase().trim();
        if (gradeWords.includes(text) || /^\d+\.?\d*$/.test(text)) {
          let gradeMatches = 0;
          for (const testRow of rows.slice(0, 3)) {
            const testCells = Array.from(testRow.querySelectorAll("td"));
            if (testCells[i]) {
              const testText = testCells[i].textContent.toLowerCase().trim();
              if (gradeWords.includes(testText) || /^\d+\.?\d*$/.test(testText)) {
                gradeMatches++;
              }
            }
          }
          if (gradeMatches >= 2) {
            log("Found grade column at index", i, "based on content analysis");
            return i;
          }
        }
      }
    }
    return -1;
  }

  function getTopRowsByNumberCell(table, n) {
    const rows = Array.from(table.querySelectorAll("tbody tr"));
    const validRows = rows.filter(tr => {
      const numberCell = tr.querySelector("td.number-cell");
      if (numberCell) return true;
      const cells = Array.from(tr.querySelectorAll("td"));
      return cells.some(cell => {
        const text = cell.textContent.trim();
        return /^#?\d+$/.test(text) && parseInt(text.replace("#", "")) > 0;
      });
    });
    validRows.sort((a, b) => {
      const getRowNumber = (row) => {
        const numberCell = row.querySelector("td.number-cell");
        if (numberCell) {
          const num = parseInt(numberCell.textContent.replace(/\D/g, ""), 10);
          return isNaN(num) ? 0 : num;
        }
        const cells = Array.from(row.querySelectorAll("td"));
        for (const cell of cells) {
          const text = cell.textContent.trim();
          if (/^#?\d+$/.test(text)) {
            const num = parseInt(text.replace(/\D/g, ""), 10);
            return isNaN(num) ? 0 : num;
          }
        }
        return 0;
      };
      return getRowNumber(b) - getRowNumber(a);
    });
    return validRows.slice(0, n);
  }

  function getCurrentDataSignature() {
    const table = getCachedTable();
    if (!table) return null;
    const rows = getTopRowsByNumberCell(table, NUM_RECENT);
    const signature = rows.map(row => {
      const cells = Array.from(row.querySelectorAll("td"));
      return cells.slice(0, 4).map(cell => cell.textContent.trim()).join("|");
    }).join("::");
    return signature || "empty";
  }

  async function calculateAndDisplayAverage(forceRecalc = false) {
    if (!onReportsRoute()) return false;
    if (isProcessing) return false;
    try {
      isProcessing = true;
      const currentSignature = getCurrentDataSignature();
      if (!forceRecalc && hasSuccessfulResult && currentSignature === lastCalculatedData && currentSignature !== null) {
        log("Data unchanged, skipping recalculation");
        return true;
      }
      updateDisplay("Average Grade Level: Switching to Quiz History tab...");
      const tabSwitched = await switchToQuizHistoryTab();
      if (!tabSwitched) {
        updateDisplayWithRetry("Average Grade Level: Could not switch to Quiz History tab");
        return false;
      }
      updateDisplay("Average Grade Level: Loading quiz data...");
      const table = await waitForQuizTable(14, 700);
      if (!table) {
        updateDisplayWithRetry("Average Grade Level: No quiz data found");
        return false;
      }
      const allRows = Array.from(table.querySelectorAll("tbody tr"));
      if (allRows.length === 0) {
        updateDisplayWithRetry("Average Grade Level: No quiz rows found");
        return false;
      }
      const colIdx = findGradeColumnIndex(table);
      if (colIdx === -1) {
        updateDisplayWithRetry("Average Grade Level: Grade level column not found");
        return false;
      }
      const recentRows = getTopRowsByNumberCell(table, NUM_RECENT);
      if (recentRows.length === 0) {
        updateDisplayWithRetry("Average Grade Level: No valid recent quizzes found");
        return false;
      }
      const grades = recentRows.map(tr => {
        const cells = Array.from(tr.querySelectorAll("td"));
        const td = cells[colIdx];
        const gradeText = td ? td.textContent.trim() : "";
        return gradeTextToNumber(gradeText);
      });
      const usedCount = grades.filter(g => g > 0).length;
      if (usedCount === 0) {
        updateDisplayWithRetry("Average Grade Level: No valid grades found");
        return false;
      }
      const avg = calculateAverage(grades);
      const mostRecentNum = getMostRecentNumber(recentRows);
      const studentName = getStudentName();
      updateDisplay(
        studentName + "\n" +
        "Average Grade Level (last " + usedCount + " quizzes): " + avg + "\n" +
        "Most recent quiz: #" + mostRecentNum
      );
      hasSuccessfulResult = true;
      lastCalculatedData = getCurrentDataSignature();
      log("Success:", avg, "from", usedCount, "quizzes");
      return true;
    } catch (error) {
      log("Calculation error:", error);
      updateDisplayWithRetry("Average Grade Level: Error - " + error.message);
      return false;
    } finally {
      isProcessing = false;
    }
  }

  function getMostRecentNumber(recentRows) {
    const firstRow = recentRows[0];
    if (!firstRow) return "unknown";
    const numberCell = firstRow.querySelector("td.number-cell");
    if (numberCell) {
      const num = parseInt(numberCell.textContent.replace(/\D/g, ""), 10);
      return isNaN(num) ? "unknown" : num;
    }
    return "unknown";
  }

  function getStudentName() {
    const nameSpan = document.querySelector(".student-report-action-container.teacher-container span.user-name");
    if (nameSpan) {
      const fullName = nameSpan.textContent.trim();
      const parts = fullName.split(",");
      if (parts.length > 1) {
        return parts[1].trim();
      }
      return fullName;
    }
    return "(Unknown Student)";
  }

  // -------- Tab control --------
  function findQuizHistoryTab() {
    const strategies = [
      () => {
        const tabs = document.querySelectorAll(".activity-tab");
        for (const tab of tabs) {
          const text = tab.textContent.trim().toLowerCase();
          if (text.includes("quiz") && text.includes("history")) return tab;
        }
        return null;
      },
      () => {
        const elements = document.querySelectorAll("a, button, [role='tab'], [class*='tab']");
        for (const elem of elements) {
          const text = elem.textContent.trim().toLowerCase();
          if (text.includes("quiz") && text.includes("history")) return elem;
        }
        return null;
      },
      () => {
        const vueElements = document.querySelectorAll("[data-v-07edfc42], [class*='quiz-history']");
        for (const elem of vueElements) {
          const t = elem.textContent.toLowerCase();
          if (t.includes("quiz") && t.includes("history")) return elem;
        }
        return null;
      }
    ];
    for (const strategy of strategies) {
      const result = strategy();
      if (result) return result;
    }
    return null;
  }

  function isQuizHistoryTabActive() {
    const activeTab = document.querySelector(".activity-tab.active, [role='tab'][aria-selected='true']");
    if (activeTab) {
      const text = activeTab.textContent.trim().toLowerCase();
      if (text.includes("quiz") && text.includes("history")) return true;
    }
    const quizTable = getCachedTable();
    const hasQuizHistoryPanel = document.querySelector(".quiz-history-panel");
    if (quizTable || hasQuizHistoryPanel) return true;
    return false;
  }

  async function switchToQuizHistoryTab() {
    if (isQuizHistoryTabActive()) return true;
    const quizTab = findQuizHistoryTab();
    if (!quizTab) return false;

    const clickEvents = ["mousedown", "mouseup", "click"];
    for (const ev of clickEvents) {
      quizTab.dispatchEvent(new MouseEvent(ev, { bubbles: true, cancelable: true, view: window }));
    }

    for (let i = 0; i < 10; i++) {
      await wait(200 + i * 60);
      if (isQuizHistoryTabActive()) {
        cachedTable = null;
        cacheTimestamp = 0;
        return true;
      }
    }
    return false;
  }

  // -------- Floating UI --------
  function updateDisplay(message) {
    const box = ensureFloatingWindow();
    box.innerHTML = message;
  }

  function updateDisplayWithRetry(message) {
    const box = ensureFloatingWindow();
    box.innerHTML = message + '<br><button onclick="window.retryCalculation()" style="margin-top: 8px; padding: 4px 8px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">Retry</button>';
  }

  function ensureFloatingWindow() {
    let el = document.getElementById("average-grade-floating-window");
    if (!el) {
      el = document.createElement("div");
      el.id = "average-grade-floating-window";
      Object.assign(el.style, {
        position: "fixed",
        bottom: "20px",
        right: "20px",
        padding: "12px",
        backgroundColor: "#ffffff",
        border: "2px solid #007bff",
        borderRadius: "8px",
        boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
        zIndex: "9999",
        fontSize: "14px",
        fontFamily: "Arial, sans-serif",
        color: "#333333",
        maxWidth: "350px",
        lineHeight: "1.4",
        whiteSpace: "pre-line"
      });
      document.body.appendChild(el);
    }
    return el;
  }

  // -------- Debounced calc trigger --------
  function debounceCalculation(delay = 600) {
    if (changeTimeout) clearTimeout(changeTimeout);
    changeTimeout = setTimeout(async () => {
      if (!isProcessing) {
        hasSuccessfulResult = false;
        await calculateAndDisplayAverage(true);
      }
    }, delay);
  }
})();