AcademicFlow

Smart attendance and grade validation overlay

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         AcademicFlow
// @namespace    mtools-by-mason
// @version      1.8.7
// @description  Smart attendance and grade validation overlay
// @author       Mason – AcademicFlow
// @match        https://mymentor.mliesl.edu/student-section*
// @match        https://mymentor.mliesl.edu/student-section/*
// @run-at       document-idle
// @grant        none
// @license      All Rights Reserved
// ==/UserScript==

(() => {
  const APP_NAME = "School Checker";
  const APP_VERSION = "1.8.7";

  const PRESENT = ["P", "T"];
  const NO_GRADE = ["A", "L", "H", "G"];
  const ATT_ABSENT = ["A", "L", "G"];

  const DEFAULT_CLASSES_PER_DAY = 5;
  const BUSINESS_TOEIC_CLASSES_PER_DAY = 10;

  

  const STORAGE_SELECTED_MODULE = "school_checker_selected_module_v1";
  const STORAGE_MINIMIZED = "school_checker_minimized_v1";
  const STORAGE_INFO_OPEN = "school_checker_info_open_v1";
  const STORAGE_ATTENDANCE_SLOTS = "school_checker_attendance_slots_v1";

  let latestGradeFixes = [];
  let latestAttendanceFills = [];
  let latestMissingGradeFills = [];
  let latestModules = new Map();
  let latestAttendanceSlots = [];
  let latestAttendanceMap = {};
  
  let latestAttendanceStats = {
  attEmpty: 0,
  attAutoFillable: 0,
  attendanceDates: 0,
  middleAbsentWarnings: 0
};

  function randomGrade() {
    return String(Math.floor(Math.random() * 31) + 70);
  }


  function getSelectedModule() {
    return localStorage.getItem(STORAGE_SELECTED_MODULE) || "ALL";
  }

  function setSelectedModule(value) {
    localStorage.setItem(STORAGE_SELECTED_MODULE, value || "ALL");
  }

  function isMinimized() {
    return localStorage.getItem(STORAGE_MINIMIZED) === "1";
  }

  function setMinimized(value) {
    localStorage.setItem(STORAGE_MINIMIZED, value ? "1" : "0");
  }

  function isModuleAllowed(moduleKey) {
    const selected = getSelectedModule();
    return selected === "ALL" || selected === moduleKey;
  }

  function markIfAllowed(input, moduleKey, className) {
    if (isModuleAllowed(moduleKey)) {
      input.classList.add(className);
    }
  }

  function countIfAllowed(stats, moduleKey, key) {
  if (isModuleAllowed(moduleKey)) {
    stats[key]++;
  }
}

  function makeSafeKey(text) {
    return String(text || "UNKNOWN")
      .replace(/\s+/g, " ")
      .trim()
      .toUpperCase();
  }

  function getClassesPerDay(table) {
    const text = (table.innerText || "").toLowerCase();
    return text.includes("business english") || text.includes("toeic")
      ? BUSINESS_TOEIC_CLASSES_PER_DAY
      : DEFAULT_CLASSES_PER_DAY;
  }

  function getAllDocs(root = document) {
    const docs = [root];

    function walk(doc) {
      doc.querySelectorAll("iframe").forEach(frame => {
        try {
          const d = frame.contentDocument || frame.contentWindow?.document;
          if (d && !docs.includes(d)) {
            docs.push(d);
            walk(d);
          }
        } catch (e) {}
      });
    }

    walk(root);
    return docs;
  }

  function normalizeDate(text) {
    const m = String(text || "").match(/\d{1,2}\/\d{1,2}\/\d{4}/);
    if (!m) return null;

    const [mm, dd, yyyy] = m[0].split("/");
    return `${mm.padStart(2, "0")}/${dd.padStart(2, "0")}/${yyyy}`;
  }

  function isPastDate(dateText) {
  const [mm, dd, yyyy] = String(dateText).split("/");
  const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd));

  const today = new Date();
  today.setHours(0, 0, 0, 0);

  return date < today; // 👈 WICHTIG: nur vor heute
}

  function injectStyle(doc) {
    if (doc.querySelector("#school-checker-style-v19")) return;

    const style = doc.createElement("style");
    style.id = "school-checker-style-v19";
    style.textContent = `
      .att-empty { outline:4px solid #ef4444 !important; background:#fee2e2 !important; }
      .att-present { outline:4px solid #10b981 !important; background:#dcfce7 !important; }
      .att-absent { outline:4px solid #f59e0b !important; background:#fef3c7 !important; }
      .att-holiday { outline:4px solid #3b82f6 !important; background:#dbeafe !important; }

      .att-middle-warning {
        outline:6px solid #ec4899 !important;
        background:#fce7f3 !important;
        box-shadow:0 0 0 3px #ec4899 inset !important;
      }

      .grade-empty {
  outline:4px solid #eab308 !important;
  background:#fef9c3 !important;
}

.grade-ok {
  outline:4px solid #10b981 !important;
  background:#dcfce7 !important;
}

.grade-e {
  outline:4px solid #64748b !important;
  background:#e2e8f0 !important;
}

.grade-conflict {
  outline:6px solid #f59e0b !important;
  background:#fef3c7 !important;
  box-shadow:0 0 0 3px #f59e0b inset !important;
}

.grade-test-makeup {
  outline:6px solid #8b5cf6 !important;
  background:#ede9fe !important;
  box-shadow:0 0 0 3px #8b5cf6 inset !important;
}

.grade-fillable {
  outline:6px solid #ef4444 !important;
  background:#fee2e2 !important;
  box-shadow:0 0 0 3px #ef4444 inset !important;
}

      #school-checker-panel {
        position:fixed;
        bottom:14px;
        left:14px;
        right:auto;
        top:auto;
        width:430px;
        z-index:999999;
        background:#0b0b0f;
        color:#fff;
        border:1px solid rgba(255,255,255,.12);
        border-radius:18px;
        box-shadow:0 20px 60px rgba(0,0,0,.35);
        font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;
        font-size:13px;
        overflow:hidden;
      }

      #school-checker-side-panel {
        position:fixed;
        bottom:14px;
        left:460px;
        right:auto;
        width:340px;
        max-height:520px;
        overflow:auto;
        z-index:999999;
        background:#0b0b0f;
        color:#fff;
        border:1px solid rgba(255,255,255,.12);
        border-radius:18px;
        box-shadow:0 20px 60px rgba(0,0,0,.35);
        font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;
        font-size:13px;
        padding:14px;
      }

      #school-checker-panel * {
        box-sizing:border-box;
      }

      .sc-header {
        padding:14px 16px;
        border-bottom:1px solid rgba(255,255,255,.10);
        display:flex;
        justify-content:space-between;
        align-items:center;
        background:linear-gradient(180deg,#15151d,#0b0b0f);
      }

      .sc-title {
        font-weight:700;
        font-size:15px;
        letter-spacing:.2px;
      }

      .sc-subtitle {
        margin-top:2px;
        color:#a1a1aa;
        font-size:12px;
      }

      .sc-version {
        color:#a1a1aa;
        font-size:12px;
      }

      .sc-body {
        padding:14px 16px 16px;
      }

      .sc-select {
        width:100%;
        margin:8px 0 12px;
        padding:9px 10px;
        border-radius:10px;
        border:1px solid rgba(255,255,255,.14);
        background:#17171f;
        color:#fff;
        font-size:12px;
        outline:none;
      }

      .sc-grid {
        display:grid;
        grid-template-columns:1fr 1fr;
        gap:8px;
        margin:10px 0 12px;
      }

      .sc-card {
        background:#17171f;
        border:1px solid rgba(255,255,255,.10);
        border-radius:12px;
        padding:10px;
      }

      .sc-label {
        color:#a1a1aa;
        font-size:11px;
        margin-bottom:4px;
      }

      .sc-value {
        font-weight:700;
        font-size:15px;
      }

      .sc-green { color:#22c55e; }
      .sc-red { color:#ef4444; }
      .sc-orange { color:#f59e0b; }
      .sc-blue { color:#38bdf8; }
      .sc-purple { color:#a78bfa; }
      .sc-pink { color:#ec4899; }
      .sc-gray { color:#94a3b8; }
      .sc-gold { color:#eab308; }

      .sc-btn {
        width:100%;
        margin-top:8px;
        padding:10px 12px;
        border:none;
        border-radius:11px;
        color:#111;
        font-weight:700;
        cursor:pointer;
        font-size:13px;
      }

      .sc-btn-att { background:#22c55e; }
      .sc-btn-fill { background:#38bdf8; }
      .sc-btn-fix { background:#f59e0b; }

      .sc-footer {
        display:flex;
        gap:8px;
        margin-top:10px;
      }

      .sc-small-btn {
        flex:1;
        padding:8px;
        border-radius:10px;
        border:1px solid rgba(255,255,255,.12);
        background:#17171f;
        color:#fff;
        cursor:pointer;
        font-size:12px;
      }

      .sc-box {
        margin-top:12px;
        padding:12px;
        border-radius:12px;
        background:#111118;
        border:1px solid rgba(255,255,255,.10);
        color:#d4d4d8;
        line-height:1.45;
      }

      .sc-box b {
        color:#fff;
      }

      .sc-mini {
        padding:12px 14px;
        display:flex;
        justify-content:space-between;
        align-items:center;
      }

      .sc-mini button {
        border:none;
        background:#22c55e;
        color:#111;
        font-weight:700;
        padding:8px 12px;
        border-radius:10px;
        cursor:pointer;
      }
    `;
    doc.head.appendChild(style);
  }
    function clearMarks(doc) {
    doc.querySelectorAll(
      ".att-empty,.att-present,.att-absent,.att-holiday,.att-middle-warning,.grade-empty,.grade-ok,.grade-e,.grade-conflict,.grade-test-makeup,.grade-fillable"
    ).forEach(el => {
      el.classList.remove(
        "att-empty",
        "att-present",
        "att-absent",
        "att-holiday",
        "att-middle-warning",
        "grade-empty",
        "grade-ok",
        "grade-e",
        "grade-conflict",
        "grade-test-makeup",
        "grade-fillable"
      );
    });
  }

  

  function saveAttendanceSlots() {
  sessionStorage.setItem(
    STORAGE_ATTENDANCE_SLOTS,
    JSON.stringify(latestAttendanceSlots)
  );
}

function loadAttendanceSlots() {
  try {
    const saved = JSON.parse(sessionStorage.getItem(STORAGE_ATTENDANCE_SLOTS) || "[]");

    if (Array.isArray(saved) && saved.length) {
      latestAttendanceSlots = saved;
    }
  } catch (e) {}
}

  function getAttendanceDates(table) {
    for (const row of [...table.querySelectorAll("tr")]) {
      const dates = [...row.querySelectorAll("td")]
        .map(td => normalizeDate(td.innerText))
        .filter(Boolean);

      if (dates.length >= 2) return dates;
    }

    return [];
  }

  function getModuleInfoFromText(text) {
    const clean = String(text || "").replace(/\s+/g, " ").trim();

    const session =
      clean.match(/(?:Summer|Fall|Winter|Spring)\s+[ABC]\s+\d{4}/i)?.[0] ||
      clean.match(/Session:\s*([^|]+)/i)?.[1]?.trim() ||
      "Unknown Season";

    const level =
      clean.match(/Program\s*\/\s*Level:\s*([^|]+)/i)?.[1]?.trim() ||
      clean.match(/Level\s*\/\s*Class Code:\s*([^|]+)/i)?.[1]?.trim() ||
      clean.match(/Program:\s*([^|]+)/i)?.[1]?.trim() ||
      "Unknown Program";

    const classCode =
      clean.match(/Class Code:\s*([^|]+)/i)?.[1]?.trim() ||
      clean.match(/Class:\s*([^|]+)/i)?.[1]?.trim() ||
      "";

    const title = [session, level, classCode].filter(Boolean).join(" — ");

    return {
      key: makeSafeKey(title),
      title
    };
  }

  function getModuleInfoForAttendanceTable(table) {
  const moduleRegex =
    /(?:Summer|Fall|Winter|Spring)\s+[ABC]\s+\d{4}[\s\S]{0,250}?(?:Program\s*\/\s*Level:|Level\s*\/\s*Class Code:|Program:|Class Code:)[\s\S]{0,250}/gi;

  const container =
    table.closest(".inner_results_table2") ||
    table.closest(".TabbedPanelsContent") ||
    document.body;

  const fullText = container.innerText || "";
  const tableText = table.innerText || "";
  const tableIndex = fullText.indexOf(tableText);

  let bestMatch = "";
  const matches = [...fullText.matchAll(moduleRegex)];

  matches.forEach(match => {
    const matchIndex = match.index || 0;

    if (tableIndex === -1 || matchIndex <= tableIndex) {
      bestMatch = match[0];
    }
  });

  if (!bestMatch && matches.length) {
    bestMatch = matches[0][0];
  }

  if (!bestMatch) {
    bestMatch = fullText;
  }

  const info = getModuleInfoFromText(bestMatch);

  if (!latestModules.has(info.key)) {
    latestModules.set(info.key, info.title);
  }

  return info;
}

function getModuleInfoForGradeTable(table) {
  let root =
    table.closest(".TabbedPanelsContent") ||
    table.closest(".inner_results_table2") ||
    table.closest("table")?.parentElement ||
    table.parentElement;

  let scope = root;
  let scopeText = "";

  for (let i = 0; scope && i < 8; i++) {
    const text = scope.innerText || "";

    if (
      /Program\s*\/\s*Level:/i.test(text) ||
      /Level\s*\/\s*Class Code:/i.test(text) ||
      /Class Code:/i.test(text) ||
      /Session:/i.test(text) ||
      /Summer|Fall|Winter|Spring/i.test(text)
    ) {
      scopeText = text;
      break;
    }

    scope = scope.parentElement;
  }

  if (!scopeText) {
    scopeText = table.closest("body")?.innerText || table.innerText || "";
  }

  const info = getModuleInfoFromText(scopeText);

  if (!latestModules.has(info.key)) {
    latestModules.set(info.key, info.title);
  }

  return info;
}

  function isMiddleAbsentPattern(dayFields) {
    const codes = dayFields.map(f => f.value).filter(Boolean);

    if (!codes.some(v => ATT_ABSENT.includes(v))) return false;

    const presentIndexes = dayFields
      .map((f, i) => PRESENT.includes(f.value) ? i : -1)
      .filter(i => i !== -1);

    if (!presentIndexes.length) return false;

    const firstP = Math.min(...presentIndexes);
    const lastP = Math.max(...presentIndexes);

    return dayFields.some((f, i) =>
      ATT_ABSENT.includes(f.value) && i > firstP && i < lastP
    );
  }

  function buildAttendanceMap(docs, stats) {
    let map = latestAttendanceMap || {};

    const hasAttendanceTables = docs.some(doc =>
  [...doc.querySelectorAll("table")].some(table =>
    table.querySelector("input.attend_text_style")
  )
);

if (hasAttendanceTables) {
  map = {};
  latestAttendanceFills = [];
  latestAttendanceSlots = [];
}

    docs.forEach(doc => {
      doc.querySelectorAll("table").forEach(table => {
        const dates = getAttendanceDates(table);

        const inputs = [...table.querySelectorAll("input.attend_text_style")]
          .filter(input =>
            input.id.includes("attend_date") &&
            !input.id.includes("makeup")
          );

        if (!dates.length || !inputs.length) return;

        const moduleInfo = getModuleInfoForAttendanceTable(table);
        const moduleKey = moduleInfo.key;

        table.setAttribute("data-school-checker-module", moduleKey);

        const classesPerDay = getClassesPerDay(table);
        const dayGroups = {};

        inputs.forEach((input, index) => {
          const value = String(input.value || "").trim().toUpperCase();
          const dateIndex = Math.floor(index / classesPerDay);
          const slotIndex = index % classesPerDay;
          const date = dates[dateIndex];

          if (!date) return;

          const moduleDateKey = `${moduleKey}|${date}`;
          const dateOnlyKey = `DATE_ONLY|${date}`;

          if (!map[moduleDateKey]) map[moduleDateKey] = [];
          if (!map[dateOnlyKey]) map[dateOnlyKey] = [];

          map[moduleDateKey].push(value);
          map[dateOnlyKey].push(value);
          latestAttendanceSlots.push({
  moduleKey,
  date,
  slotIndex,
  value
});

          if (!dayGroups[date]) dayGroups[date] = [];
          dayGroups[date][slotIndex] = {
            input,
            value,
            date,
            slotIndex
          };

          if (!value) {
  if (isModuleAllowed(moduleKey) && isPastDate(date)) {
    input.classList.add("att-empty");

    latestAttendanceFills.push({
      input,
      date,
      slot: slotIndex + 1,
      moduleKey,
      moduleTitle: moduleInfo.title
    });

    stats.attEmpty++;
    stats.attAutoFillable++;
  }
} else if (isModuleAllowed(moduleKey) && PRESENT.includes(value)) {
            input.classList.add("att-present");
          } else if (isModuleAllowed(moduleKey) && value === "H") {
            input.classList.add("att-holiday");
          } else if (isModuleAllowed(moduleKey) && NO_GRADE.includes(value)) {
            input.classList.add("att-absent");
          }
        });

        Object.values(dayGroups).forEach(group => {
          const filledGroup = group.filter(Boolean);

          if (filledGroup.length !== classesPerDay) return;

          if (isMiddleAbsentPattern(filledGroup)) {
            filledGroup.forEach(f => {
              if (ATT_ABSENT.includes(f.value) && isModuleAllowed(moduleKey)) {
                f.input.classList.add("att-middle-warning");
              }
            });

            if (isModuleAllowed(moduleKey)) {
              stats.middleAbsentWarnings++;
            }
          }
        });
      });
    });
if (hasAttendanceTables) {
  latestAttendanceMap = map;
  saveAttendanceSlots();
}
    return map;
  }

  function isGradeTable(table) {
    return /\d{1,2}\/\d{1,2}\/\d{4}/.test(table.innerText || "") &&
      table.querySelector("input");
  }

  function getGradeDates(table) {
    const firstRow = table.querySelector("tr");
    if (!firstRow) return [];

    return [...firstRow.querySelectorAll("td")]
      .map(td => normalizeDate(td.innerText));
  }

  function isZero(value) {
    return String(value).trim() === "0";
  }

  function isValid70To100(value) {
    const raw = String(value).trim();
    const num = Number(raw);

    return raw !== "" && !Number.isNaN(num) && num >= 70 && num <= 100;
  }

  function getGradeTabName(table) {
  const names = [];
  let current = table.closest(".TabbedPanelsContent");

  while (current) {
    const group = current.parentElement;
    const panel = group?.closest(".TabbedPanels");

    if (group && panel) {
      const contents = [...group.children].filter(el =>
        el.classList?.contains("TabbedPanelsContent")
      );

      const index = contents.indexOf(current);

      const tabs = [
        ...panel.querySelectorAll(
          ":scope > .TabbedPanelsTabGroup1 > .TabbedPanelsTab1, " +
          ":scope > .TabbedPanelsTabGroup > .TabbedPanelsTab, " +
          ".TabbedPanelsTab1, .TabbedPanelsTab"
        )
      ];

      const name = tabs[index]?.innerText?.trim()?.toUpperCase();

      if (name) names.unshift(name);
    }

    current = panel?.parentElement?.closest(".TabbedPanelsContent");
  }

  const tableText = (table.innerText || "").toUpperCase();

  return [...names, tableText].join(" > ") || "UNKNOWN";
}

  function isTestLikeTab(tabName) {
    return tabName.includes("TEST") ||
      tabName.includes("PRACTICE") ||
      tabName.includes("PREP");
  }

  function isHomeworkTab(tabName) {
    return tabName.includes("HOMEWORK");
  }

  function isParticipationTab(tabName) {
  return tabName.includes("PARTICIPATION") || getParticipationSubject(tabName) !== null;
}

  function getSuggestedMissingGrade(tabName) {
    if (isHomeworkTab(tabName)) return "100";
    if (isParticipationTab(tabName)) return randomGrade();
    if (isTestLikeTab(tabName)) return randomGrade();

    return randomGrade();
  }

function addDaysToDate(dateText, days) {
  const [mm, dd, yyyy] = String(dateText).split("/");
  const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd));

  date.setDate(date.getDate() + days);

  const newMm = String(date.getMonth() + 1).padStart(2, "0");
  const newDd = String(date.getDate()).padStart(2, "0");
  const newYyyy = date.getFullYear();

  return `${newMm}/${newDd}/${newYyyy}`;
}

function getDateObj(dateText) {
  const [mm, dd, yyyy] = String(dateText).split("/");
  return new Date(Number(yyyy), Number(mm) - 1, Number(dd));
}

function formatDateObj(date) {
  const mm = String(date.getMonth() + 1).padStart(2, "0");
  const dd = String(date.getDate()).padStart(2, "0");
  const yyyy = date.getFullYear();

  return `${mm}/${dd}/${yyyy}`;
}

function getParticipationRangeDates(startDateText, nextDateText) {
  const start = getDateObj(startDateText);
  const end = nextDateText
    ? getDateObj(nextDateText)
    : getDateObj(addDaysToDate(startDateText, 7));

  const dates = [];
  const current = new Date(start);

  while (current < end) {
    dates.push(formatDateObj(current));
    current.setDate(current.getDate() + 1);
  }

  return dates;
}

function getParticipationSubject(tabName) {
  const t = String(tabName || "").toUpperCase();

  if (t.includes("LISTENING")) return "LISTENING";
  if (t.includes("GRAMMAR") || t.includes("WRITING")) return "GRAMMAR";
  if (t.includes("CMAR")) return "CMAR";
  if (t.includes("IDIOM")) return "IDIOMS";

  if (
    t.includes("DISCUSSION") ||
    t.includes("CONVERSATION") ||
    t.includes("SPEAKING") ||
    t.includes("READING") ||
    t.includes("COMMUNICATION")
  ) {
    return "DISCUSSION";
  }

  return null;
}


function isSlotRelevantForParticipation(subject, dayIndex, slotIndex) {
  const isMonOrWed = dayIndex === 0 || dayIndex === 2;
  const isTueOrThu = dayIndex === 1 || dayIndex === 3;

  if (subject === "LISTENING") {
    if (isMonOrWed) return slotIndex >= 0 && slotIndex <= 2;
    if (isTueOrThu) return slotIndex >= 0 && slotIndex <= 1;
  }

  if (subject === "GRAMMAR") {
    if (isMonOrWed) return slotIndex >= 3 && slotIndex <= 4;
    if (isTueOrThu) return slotIndex >= 2 && slotIndex <= 4;
  }

  if (subject === "CMAR") {
    return slotIndex === 0;
  }

  if (subject === "IDIOMS") {
    if (isMonOrWed) return slotIndex >= 1 && slotIndex <= 2;
    if (isTueOrThu) return slotIndex === 1;
  }

  if (subject === "DISCUSSION") {
    if (isMonOrWed) return slotIndex >= 3 && slotIndex <= 4;
    if (isTueOrThu) return slotIndex >= 2 && slotIndex <= 4;
  }

  return false;
}





  function getAttendanceForGrade(attendanceMap, moduleKey, date) {
  const moduleDateKey = `${moduleKey}|${date}`;
  const dateOnlyKey = `DATE_ONLY|${date}`;

  let attendance = (attendanceMap[moduleDateKey] || []).filter(Boolean);

  if (!attendance.length) {
    attendance = (attendanceMap[dateOnlyKey] || []).filter(Boolean);
  }

  if (attendance.length) {
    return attendance;
  }

  // Business English + TOEIC special case:
  // If grade date has no direct attendance match,
  // check the next date and use the first 5 boxes.
  const nextDate = addDaysToDate(date, 1);

  const nextModuleDateKey = `${moduleKey}|${nextDate}`;
  const nextDateOnlyKey = `DATE_ONLY|${nextDate}`;

  const nextModuleAttendance =
    (attendanceMap[nextModuleDateKey] || []).filter(Boolean);

  if (nextModuleAttendance.length >= BUSINESS_TOEIC_CLASSES_PER_DAY) {
    return nextModuleAttendance.slice(0, DEFAULT_CLASSES_PER_DAY);
  }

  const nextDateOnlyAttendance =
    (attendanceMap[nextDateOnlyKey] || []).filter(Boolean);

  if (nextDateOnlyAttendance.length >= BUSINESS_TOEIC_CLASSES_PER_DAY) {
    return nextDateOnlyAttendance.slice(0, DEFAULT_CLASSES_PER_DAY);
  }

  return [];
}
    

  function renderModuleSelect(panel) {
    const select = panel.querySelector("#school-checker-module-select");
    if (!select) return;

    const current = getSelectedModule();

    select.innerHTML = `<option value="ALL">All Modules</option>`;

    latestModules.forEach((title, key) => {
      const opt = document.createElement("option");

      opt.value = key;
      opt.textContent = title;

      if (key === current) {
        opt.selected = true;
      }

      select.appendChild(opt);
    });

    select.onchange = () => {
      setSelectedModule(select.value);
      runChecker({ skipFocusGuard: true });
    };
  }

  function getInfoOpen() {
    return localStorage.getItem(STORAGE_INFO_OPEN) || "";
  }

  function setInfoOpen(type) {
    localStorage.setItem(STORAGE_INFO_OPEN, type || "");
  }

  function getInfoContent(type) {
  if (type === "info") {
    return `
      <b>How it works</b><br><br>
      AcademicFlow checks attendance and grade records directly in the browser.
      It highlights missing attendance, missing grades, conflicts, and patterns that may require review.<br><br>

      <b>Attendance colors</b><br>
      <span class="sc-green">Green</span> = Present / Tardy<br>
      <span class="sc-red">Red</span> = Missing attendance<br>
      <span class="sc-orange">Orange</span> = Absent / Leave code<br>
      <span class="sc-blue">Blue</span> = Holiday<br>
      <span class="sc-pink">Pink</span> = Attendance sequence warning<br><br>

      <b>Grade colors</b><br>
      <span class="sc-green">Green</span> = Correct / no issue found<br>
      <span class="sc-gold">Gold</span> = Missing grade / missing attendance match<br>
      <span class="sc-red">Red</span> = Missing grade that needs review<br>
      <span class="sc-orange">Orange</span> = Review needed<br>
      <span class="sc-purple">Purple</span> = Special case / makeup review<br>
      <span class="sc-gray">Gray</span> = Excused grade
    `;
  }

  if (type === "rules") {
  return `
    <b>How to use</b><br><br>
    1. Open <b>Attendance</b> first to load attendance data.<br>
    2. Open <b>Grades</b> after that to activate the checker.<br>
    3. Select a module if you only want to review one module.<br>
    4. For grade review, open each grade tab separately so the checker can read the fields.<br>
    5. Tabs such as <b>Grammar & Writing</b>, <b>Listening</b>, <b>Speaking & Reading</b>, <b>CMAR</b>, <b>Idioms</b>, and <b>Discussion</b> should be selected one by one.<br><br>

    <b>Important</b><br>
    AcademicFlow is designed to assist with attendance and grade review.  
  `;
}

  if (type === "updates") {
  return `
    <b>Version ${APP_VERSION}</b><br><br>

    <b style="color:#22c55e;">New in this version</b><br>
    - Improved review workflow and interface<br>
    - Cleaner validation and highlighting system<br>
    - Improved module filtering and review flow<br>
    - Updated instructions and color indicators<br>
    - Performance and stability improvements<br><br>

    <span style="color:#71717a;">
      <b>Previous updates</b><br>
      - Participation review improvements<br>
      - Better attendance and grade validation<br>
      - Business English + TOEIC support<br>
      - Improved module detection<br>
      - Practice Prep / Practice Test review support
    </span><br><br>

    <b>Update link</b><br>
    <a href="https://update.greasyfork.org/scripts/576027/AcademicFlow.user.js" target="_blank" style="color:#38bdf8;text-decoration:none;font-weight:700;">
      Install / Update AcademicFlow
    </a>
  `;
}

  return "";
}

  function renderSidePanel() {
    let side = document.querySelector("#school-checker-side-panel");
    const activeInfo = getInfoOpen();

    if (!activeInfo) {
      if (side) side.remove();
      return;
    }

    if (!side) {
      side = document.createElement("div");
      side.id = "school-checker-side-panel";
      document.body.appendChild(side);
    }

    const title =
      activeInfo === "info" ? "Info" :
      activeInfo === "rules" ? "Instructions" :
      activeInfo === "updates" ? "Updates" :
      "Information";

    side.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
        <b>${title}</b>
        <button id="school-checker-side-close" style="background:#17171f;color:white;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 9px;cursor:pointer;">Close</button>
      </div>
      <div class="sc-box">${getInfoContent(activeInfo)}</div>
    `;

    side.querySelector("#school-checker-side-close").onclick = () => {
      setInfoOpen("");
      renderSidePanel();
    };
  }

  function toggleInfoBox(type) {
    const current = getInfoOpen();

    setInfoOpen(current === type ? "" : type);
    runChecker({ skipFocusGuard: true });
  }

  function getWeekAttendanceForParticipation(moduleKey, dateText, nextDateText) {
  const weekDates = getParticipationRangeDates(dateText, nextDateText);

  let rows = latestAttendanceSlots.filter(row =>
    row.moduleKey === moduleKey &&
    weekDates.includes(row.date)
  );

  if (!rows.length) {
    rows = latestAttendanceSlots.filter(row =>
      weekDates.includes(row.date)
    );
  }

  return rows
    .map(row => String(row.value || "").trim().toUpperCase())
    .filter(v => v && v !== "H");
}

  function checkGradeInput({
    input,
    index,
    date,
nextDate,
value,
attendance,
    moduleKey,
    moduleInfo,
    tabName,
    isTestTab,
    stats
  }) {
    const hasPresent = attendance.some(v => PRESENT.includes(v));
    const hasNoGradeCode = attendance.some(v => NO_GRADE.includes(v));

    const allNoGrade =
      attendance.length > 0 &&
      attendance.every(v => NO_GRADE.includes(v));

    if (!isPastDate(date)) {
  return;
}

stats.gradesChecked++;

if (isParticipationTab(tabName)) {
  const weekAttendance = getWeekAttendanceForParticipation(moduleKey, date, nextDate);

  if (!weekAttendance.length) {
    markIfAllowed(input, moduleKey, "grade-empty");
    return;
  }

  const presentCount = weekAttendance.filter(v => PRESENT.includes(v)).length;
  const absentCount = weekAttendance.filter(v => NO_GRADE.includes(v)).length;

  let recommended;

  if (absentCount === weekAttendance.length) {
    recommended = 0;
  } else if (presentCount === weekAttendance.length) {
    recommended = 100;
  } else {
    recommended = randomGrade();
  }

  if (!value) {
    markIfAllowed(input, moduleKey, "grade-fillable");

    if (isModuleAllowed(moduleKey)) {
      stats.missingGradeFillable++;

      latestMissingGradeFills.push({
        input,
        date,
        tab: "PARTICIPATION",
        newValue: String(recommended),
        moduleKey,
        moduleTitle: moduleInfo.title
      });
    }

    return;
  }

  const current = Number(value);

  if (!Number.isNaN(current) && current >= 70 && current <= 100) {
    markIfAllowed(input, moduleKey, "grade-ok");
    stats.ok++;
    return;
  }

  markIfAllowed(input, moduleKey, "grade-conflict");
  countIfAllowed(stats, moduleKey, "gradeConflicts");

  if (isModuleAllowed(moduleKey)) {
    latestGradeFixes.push({
      input,
      date,
      oldValue: value,
      tab: "PARTICIPATION",
      moduleKey,
      moduleTitle: moduleInfo.title,
      forceValue: String(recommended)
    });
  }

  return;
}

  

    // Business English / TOEIC Practice Prep:
// Reading + Listening are manual score bars.
// Manual review tab. Empty = review, filled = OK.
if (tabName.includes("PRACTICE PREP") || tabName.includes("PRACTICE TEST")) {
  if (!value) {
    markIfAllowed(input, moduleKey, "grade-empty");
  } else {
    markIfAllowed(input, moduleKey, "grade-ok");
    stats.ok++;
  }

  return;
}

    if (isTestTab && index === 0) {
      if (!value) {
        markIfAllowed(input, moduleKey, "grade-ok");
        stats.ok++;
        stats.testFirstEmptyOk++;
      } else {
        markIfAllowed(input, moduleKey, "grade-conflict");
        countIfAllowed(stats, moduleKey, "gradeConflicts");
        countIfAllowed(stats, moduleKey, "testFirstFieldConflicts");

        if (isModuleAllowed(moduleKey)) {
          latestGradeFixes.push({
            input,
            date,
            oldValue: value,
            tab: tabName + " FIRST FIELD",
            moduleKey,
            moduleTitle: moduleInfo.title,
            clearInsteadOfZero: true
          });
        }
      }

      return;
    }

    if (!attendance.length) {
  stats.noAttendanceMatch++;

  if (!value) {
    markIfAllowed(input, moduleKey, "grade-empty"); // Gold
  } else if (value === "E") {
    markIfAllowed(input, moduleKey, "grade-e"); // Gray
    stats.e++;
  } else if (isHomeworkTab(tabName)) {
    markIfAllowed(input, moduleKey, "grade-test-makeup"); // Purple
    countIfAllowed(stats, moduleKey, "makeupWarnings");
  } else {
    markIfAllowed(input, moduleKey, "grade-conflict"); // Orange
    countIfAllowed(stats, moduleKey, "gradeConflicts");
  }

  return;
}

    if (value === "E") {
      markIfAllowed(input, moduleKey, "grade-e");
      stats.e++;
      return;
    }



  
    if (hasPresent) {
      const num = Number(value);
      const isNum = value !== "" && !Number.isNaN(num);

      if (!value) {
        markIfAllowed(input, moduleKey, "grade-fillable");

        if (isModuleAllowed(moduleKey)) {
          const newValue = getSuggestedMissingGrade(tabName);

          stats.missingGradeFillable++;

          latestMissingGradeFills.push({
            input,
            date,
            tab: tabName,
            newValue,
            moduleKey,
            moduleTitle: moduleInfo.title
          });
        }

        return;
      }

      if (isHomeworkTab(tabName)) {
  if (value === "100") {
    markIfAllowed(input, moduleKey, "grade-ok");
    stats.ok++;
  } else {
    markIfAllowed(input, moduleKey, "grade-conflict");
    countIfAllowed(stats, moduleKey, "gradeConflicts");

    if (isModuleAllowed(moduleKey)) {
      latestGradeFixes.push({
        input,
        date,
        oldValue: value || "empty",
        tab: tabName,
        moduleKey,
        moduleTitle: moduleInfo.title,
        forceValue: "100"
      });
    }
  }

  return;
}

      

  

      if (isTestTab) {
        if (value === "0") {
          markIfAllowed(input, moduleKey, "grade-conflict");
          countIfAllowed(stats, moduleKey, "gradeConflicts");

          if (isModuleAllowed(moduleKey)) {
            latestGradeFixes.push({
              input,
              date,
              oldValue: value,
              tab: tabName,
              moduleKey,
              moduleTitle: moduleInfo.title,
              forceValue: randomGrade()
            });
          }
        } else {
          markIfAllowed(input, moduleKey, "grade-ok");
          stats.ok++;
        }

        return;
      }

      markIfAllowed(input, moduleKey, "grade-ok");
      stats.ok++;
      return;
    }
        if (allNoGrade || hasNoGradeCode) {
  if (isHomeworkTab(tabName)) {
    if (!value) {
      markIfAllowed(input, moduleKey, "grade-fillable");

      if (isModuleAllowed(moduleKey)) {
        stats.missingGradeFillable++;

        latestMissingGradeFills.push({
          input,
          date,
          tab: tabName + " ABSENT HOMEWORK",
          newValue: "0",
          moduleKey,
          moduleTitle: moduleInfo.title
        });
      }
    } else if (isZero(value)) {
      markIfAllowed(input, moduleKey, "grade-ok");
      stats.ok++;
    } else {
      markIfAllowed(input, moduleKey, "grade-test-makeup");
      countIfAllowed(stats, moduleKey, "makeupWarnings");
    }

    return;
  }

      if (!value || isZero(value)) {
  markIfAllowed(input, moduleKey, "grade-ok");
  stats.ok++;

  if (!value) {
    stats.emptyOk++;
  }
} else if (isTestTab) {
        markIfAllowed(input, moduleKey, "grade-test-makeup");
        countIfAllowed(stats, moduleKey, "makeupWarnings");
      } else {
        markIfAllowed(input, moduleKey, "grade-conflict");
        countIfAllowed(stats, moduleKey, "gradeConflicts");

        if (isModuleAllowed(moduleKey)) {
          latestGradeFixes.push({
            input,
            date,
            oldValue: value,
            tab: tabName,
            moduleKey,
            moduleTitle: moduleInfo.title
          });
        }
      }

      return;
    }

    if (!value) {
      markIfAllowed(input, moduleKey, "grade-empty");
    } else {
      markIfAllowed(input, moduleKey, "grade-ok");
      stats.ok++;
    }
  }

  function runChecker(options = {}) {
    const active = document.activeElement;

    if (
      !options.skipFocusGuard &&
      active &&
      active.id === "school-checker-module-select"
    ) {
      return;
    }

    const docs = getAllDocs();

    latestGradeFixes = [];
latestMissingGradeFills = [];

// latestModules NICHT jedes Mal löschen,
// sonst verschwinden Module beim Tab-Wechsel.

// Attendance NICHT jedes Mal löschen.
// Sonst verliert Grades die Attendance-Daten beim Tab-Wechsel.

    const stats = {
      attendanceDates: 0,
      gradesChecked: 0,
      ok: 0,
      attEmpty: 0,
      attAutoFillable: 0,
      middleAbsentWarnings: 0,
      missingGradeFillable: 0,
      gradeConflicts: 0,
      makeupWarnings: 0,
      testFirstEmptyOk: 0,
      testFirstFieldConflicts: 0,
      e: 0,
      emptyOk: 0,
      noAttendanceMatch: 0
    };

    docs.forEach(doc => {
      injectStyle(doc);
      clearMarks(doc);
    });

    const hasAttendanceVisible = docs.some(doc =>
  [...doc.querySelectorAll("input.attend_text_style")].some(input =>
    input.id.includes("attend_date") && !input.id.includes("makeup")
  )
);

let attendanceMap = latestAttendanceMap || {};

if (hasAttendanceVisible) {
  attendanceMap = buildAttendanceMap(docs, stats) || latestAttendanceMap || {};
} else {
  loadAttendanceSlots();
}


if (hasAttendanceVisible) {
  latestAttendanceStats = {
    attEmpty: stats.attEmpty,
    attAutoFillable: stats.attAutoFillable,
    attendanceDates: Object.keys(attendanceMap).length,
    middleAbsentWarnings: stats.middleAbsentWarnings
  };
} else {
  stats.attEmpty = latestAttendanceStats.attEmpty;
  stats.attAutoFillable = latestAttendanceStats.attAutoFillable;
  stats.middleAbsentWarnings = latestAttendanceStats.middleAbsentWarnings;
}
    stats.attendanceDates = hasAttendanceVisible
  ? Object.keys(attendanceMap).length
  : latestAttendanceStats.attendanceDates;

    docs.forEach(doc => {
      doc.querySelectorAll("table").forEach(table => {

        if (!isGradeTable(table)) return;

        const moduleInfo = getModuleInfoForGradeTable(table);
        const moduleKey = moduleInfo.key;

        table.setAttribute("data-school-checker-module", moduleKey);

        const tabName = getGradeTabName(table);
        const isTestTab = isTestLikeTab(tabName);
        const dates = getGradeDates(table);

        const inputs = [
          ...table.querySelectorAll("input[type='text'], input:not([type])")
        ];

        inputs.forEach((input, index) => {
          const value = String(input.value || "").trim().toUpperCase();
          const date = dates[index];
          const nextDate = dates[index + 1] || null;

          if (!date) return;

          const attendance = getAttendanceForGrade(
            attendanceMap,
            moduleKey,
            date
          );

          checkGradeInput({
            input,
            index,
            date,
nextDate,
value,
attendance,
            moduleKey,
            moduleInfo,
            tabName,
            isTestTab,
            stats
          });
        });
      });
    });

    let panel = document.querySelector("#school-checker-panel");

    if (!panel) {
      panel = document.createElement("div");
      panel.id = "school-checker-panel";
      document.body.appendChild(panel);
    }

    if (isMinimized()) {
      panel.innerHTML = `
        <div class="sc-mini">
          <div>
            <div class="sc-title">${APP_NAME}</div>
            <div class="sc-subtitle">v${APP_VERSION}</div>
          </div>
          <button id="school-checker-expand">Open</button>
        </div>
      `;

      renderSidePanel();

      panel.querySelector("#school-checker-expand").onclick = () => {
        setMinimized(false);
        runChecker({ skipFocusGuard: true });
      };

      return;
    }

    panel.innerHTML = `
      <div class="sc-header">
        <div>
          <div class="sc-title">${APP_NAME}</div>
          <div class="sc-subtitle">Review Assistant</div>
        </div>
        <div class="sc-version">v${APP_VERSION}</div>
      </div>

      <div class="sc-body">
        <select id="school-checker-module-select" class="sc-select"></select>

        <div class="sc-grid">
          <div class="sc-card">
            <div class="sc-label">Missing Attendance</div>
            <div class="sc-value sc-red">${stats.attEmpty}</div>
          </div>

          <div class="sc-card">
            <div class="sc-label">Missing Grades</div>
            <div class="sc-value sc-red">${stats.missingGradeFillable}</div>
          </div>

          <div class="sc-card">
            <div class="sc-label">Review Needed</div>
            <div class="sc-value sc-orange">${stats.gradeConflicts}</div>
          </div>

          <div class="sc-card">
            <div class="sc-label">Checked OK</div>
            <div class="sc-value sc-green">${stats.ok}</div>
          </div>
        </div>

        <div class="sc-card">
          <div class="sc-label">Details</div>
          Modules detected: ${latestModules.size}<br>
          Dates: ${stats.attendanceDates}<br>
          Grades checked: ${stats.gradesChecked}<br>
          Test first field issues:
          <span class="sc-orange">${stats.testFirstFieldConflicts}</span><br>
          Makeup warnings:
          <span class="sc-purple">${stats.makeupWarnings}</span><br>
          Attendance pattern warnings:
          <span class="sc-pink">${stats.middleAbsentWarnings}</span><br>
          Missing grade / attendance match:
<span class="sc-gold">${stats.noAttendanceMatch}</span>
        </div>

        

        <div class="sc-footer">
          <button class="sc-small-btn" id="school-checker-info">Info</button>
          <button class="sc-small-btn" id="school-checker-rules">Instructions</button>
          <button class="sc-small-btn" id="school-checker-updates">Updates</button>
          <button class="sc-small-btn" id="school-checker-minimize">Minimize</button>
        </div>
      </div>
    `;

    renderSidePanel();
    renderModuleSelect(panel);

    

    panel.querySelector("#school-checker-info").onclick = e => {
      e.stopPropagation();
      toggleInfoBox("info");
    };

    panel.querySelector("#school-checker-rules").onclick = e => {
      e.stopPropagation();
      toggleInfoBox("rules");
    };

    panel.querySelector("#school-checker-updates").onclick = e => {
      e.stopPropagation();
      toggleInfoBox("updates");
    };

    panel.querySelector("#school-checker-minimize").onclick = () => {
      setMinimized(true);
      runChecker({ skipFocusGuard: true });
    };

    console.log(`${APP_NAME} v${APP_VERSION}`, stats);
  }

  runChecker({ skipFocusGuard: true });

  setInterval(() => {
  const active = document.activeElement;

  if (active && active.id === "school-checker-module-select") return;

  runChecker();
}, 3000);
  document.addEventListener("click", () => {
    setTimeout(() => runChecker(), 300);
    setTimeout(() => runChecker(), 900);
  }, true);

  console.log(`${APP_NAME} v${APP_VERSION} active.`);
})();