AcademicFlow

Smart attendance and grade validation overlay

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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.`);
})();