ReadTheory Average Grade Level Calculator (Past 15 Quizzes)

Calculates the average grade level from the past 15 (or fewer) quizzes available on ReadTheory

// ==UserScript==
// @name         ReadTheory Average Grade Level Calculator (Past 15 Quizzes)
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      1.12
// @description  Calculates the average grade level from the past 15 (or fewer) quizzes available on ReadTheory
// @author       Stuart Saddler
// @icon         https://images-na.ssl-images-amazon.com/images/I/41Y-bktG5oL.png
// @license      MIT
// @match        *://readtheory.org/app/teacher/reports/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const NUM_RECENT = 15;

  // Map words to numbers; also allow raw digits like "5" or "5.0"
  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) {
    // Look only at header row cells
    const ths = table.querySelectorAll("thead th, tr:first-child th");
    let idx = -1;
    ths.forEach((th, i) => {
      if (String(th.textContent || "").toLowerCase().includes("grade level")) {
        idx = i;
      }
    });
    return idx; // 0-based
  }

  function getRecentGradeCells(table, gradeColIdx) {
    // Pull the first NUM_RECENT body rows from the grade column
    const rows = Array.from(table.querySelectorAll("tbody tr")).slice(0, NUM_RECENT);
    const tds = rows.map(r => r.querySelectorAll("td")[gradeColIdx]).filter(Boolean);
    return tds;
  }

  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: "100px",
        right: "20px",
        padding: "10px",
        backgroundColor: "#f1f1f1",
        border: "2px solid #333",
        zIndex: "9999",
        boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
        fontSize: "16px",
        color: "#333",
        maxWidth: "320px",
        borderRadius: "6px",
        lineHeight: "1.3"
      });
      document.body.appendChild(el);
    }
    return el;
  }

  function calculateAndDisplayAverage() {
    try {
      const table = document.querySelector("table");
      if (!table) {
        console.warn("No table found yet.");
        return;
      }

      const colIdx = findGradeColumnIndex(table);
      if (colIdx === -1) {
        console.error("Could not find the 'Grade Level' column.");
        return;
      }

      const gradeCells = getRecentGradeCells(table, colIdx);
      if (gradeCells.length === 0) {
        console.warn("No grade cells found yet.");
        return;
      }

      const grades = gradeCells.map(td => gradeTextToNumber(td.textContent));
      const avg = calculateAverage(grades);

      const box = ensureFloatingWindow();
      box.textContent = "Average Grade Level (Last " + grades.filter(g => g > 0).length + " Quizzes): " + avg;
      console.log("Average grade level:", avg);
    } catch (e) {
      console.error("Average grade calculation failed:", e);
    }
  }

  // Observe content changes and recalc once the table is present
  let initObserverStarted = false;
  function startInitObserver() {
    if (initObserverStarted) return;
    initObserverStarted = true;

    const obs = new MutationObserver(function (ml, ob) {
      const table = document.querySelector("table");
      const hasHeader = table && table.querySelector("th");
      if (hasHeader) {
        ob.disconnect();
        calculateAndDisplayAverage();
      }
    });
    obs.observe(document.body, { childList: true, subtree: true });
  }

  // Recalculate when student selection changes; avoid stacking observers
  let dropdownListenerAttached = false;
  function watchStudentDropdown() {
    if (dropdownListenerAttached) return;
    const selects = Array.from(document.querySelectorAll("select"));
    if (selects.length === 0) return;

    const handler = function () {
      // Give the page a moment to swap in new rows
      setTimeout(calculateAndDisplayAverage, 1200);
    };

    selects.forEach(sel => sel.addEventListener("change", handler));
    document.addEventListener("change", function (ev) {
      if (ev.target && ev.target.tagName === "SELECT") {
        setTimeout(calculateAndDisplayAverage, 1200);
      }
    });
    dropdownListenerAttached = true;
  }

  // Watch a specific panel attribute if present
  let idObserver;
  function watchStudentIDAttribute() {
    const panel = document.querySelector(".quiz-history-panel");
    if (!panel) return;
    if (idObserver) idObserver.disconnect();

    idObserver = new MutationObserver(function (ml) {
      for (const m of ml) {
        if (m.type === "attributes" && m.attributeName === "studentid") {
          setTimeout(calculateAndDisplayAverage, 600);
          break;
        }
      }
    });
    idObserver.observe(panel, { attributes: true, attributeFilter: ["studentid"] });
  }

  function init() {
    startInitObserver();
    watchStudentDropdown();
    watchStudentIDAttribute();
    // First attempt after idle
    calculateAndDisplayAverage();
  }

  if (document.readyState === "complete" || document.readyState === "interactive") {
    setTimeout(init, 0);
  } else {
    window.addEventListener("load", init);
    document.addEventListener("DOMContentLoaded", init);
  }
})();