Moodle Grade Dashboard & Predictor

Adds a category-aware grade predictor with goal seeking, alerts, and visualizations, plus an "All Grades" dashboard with trend charts.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Moodle Grade Dashboard & Predictor
// @namespace   Violentmonkey Scripts
// @match       https://moodle.colby.edu/grade/report/user/index.php*
// @match       https://moodle.colby.edu/my/
// @grant       GM_addStyle
// @license     MIT
// @version     4.1
// @author      -
// @description Adds a category-aware grade predictor with goal seeking, alerts, and visualizations, plus an "All Grades" dashboard with trend charts.
// @description:fix v4.1 - Fixed a critical syntax error that occurred when adding a new hypothetical assignment row.
// ==/UserScript==

(function() {
    'use strict';

    // --- SHARED UTILITIES ---
    const STORAGE_KEY = 'moodleGradeTracker';
    const getStoredData = () => JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
    const setStoredData = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));

    // --- STYLES ---
    GM_addStyle(`
        /* --- General Predictor Styles --- */
        #gradePredictorContainer { margin-top: 40px; padding: 25px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
        #gradePredictorContainer h2 { margin-top: 0; margin-bottom: 20px; border-bottom: 2px solid #007bff; padding-bottom: 10px; color: #333; }
        .predictor-body { display: flex; gap: 30px; flex-wrap: wrap; }
        .predictor-assignments { flex: 3; min-width: 450px; }
        .predictor-sidebar { flex: 2; min-width: 300px; display: flex; flex-direction: column; gap: 20px; }
        .predictor-module { padding: 20px; background-color: #fff; border-radius: 5px; border: 1px solid #ddd; text-align: center; }
        .predictor-module h4 { margin-top: 0; color: #007bff; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; }
        .grade-category h4 { margin: 15px 0 10px 0; color: #555; font-size: 16px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }
        .assignment-row { display: flex; justify-content: space-between; margin-bottom: 12px; align-items: center; padding: 8px; border-radius: 4px; background-color: #fff; border: 1px solid #eee; }
        .assignment-row input { padding: 6px; border: 1px solid #ccc; border-radius: 4px; }
        .assignment-name { flex-grow: 1; font-size: 14px; margin-right: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
        input.hypothetical-name { width: 40%; margin-right: 10px; }
        input.hypothetical-grade, input.hypothetical-max-grade { width: 60px; }
        input.hypothetical-weight { width: 70px; margin-left: 10px; }
        #addAssignmentBtn { margin-top: 20px; padding: 10px 15px; width: 100%; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
        #predictedGrade { font-size: 48px; font-weight: bold; color: #0056b3; margin: 10px 0; }
        #predictedLetterGrade { font-size: 24px; font-weight: bold; color: #495057; }
        #totalWeightInfo { font-size: 12px; color: #6c757d; margin-top: 15px; }

        /* --- Goal Seeker Styles --- */
        #goalSeeker .goal-inputs { display: flex; gap: 10px; justify-content: center; margin-bottom: 15px; }
        #goalSeeker input { width: 100px; text-align: center; padding: 5px; }
        #goalSeeker button { padding: 8px 12px; background-color: #17a2b8; color: white; border: none; border-radius: 4px; cursor: pointer; }
        #goalSeekerResult { font-size: 18px; font-weight: bold; margin-top: 10px; min-height: 25px; }

        /* --- Alerts Styles --- */
        #gradeAlerts .alert { padding: 12px; margin-bottom: 10px; border-radius: 5px; text-align: left; }
        #gradeAlerts .alert-critical { background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
        #gradeAlerts .alert-warning { background-color: #fff3cd; border: 1px solid #ffeeba; color: #856404; }
        #gradeAlerts .alert-ok { background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; }

        /* --- Chart Styles --- */
        #categoryWeightChartContainer { position: relative; }
        #categoryWeightChart { display: block; margin: 10px auto; }
        #chartLegend { list-style: none; padding: 0; margin-top: 15px; display: flex; flex-wrap: wrap; justify-content: center; gap: 15px; }
        #chartLegend li { display: flex; align-items: center; font-size: 12px; }
        #chartLegend span { width: 12px; height: 12px; border-radius: 50%; margin-right: 6px; }
        .trend-chart-container { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; }

        /* --- Dashboard Modal Styles --- */
        #allGradesBtn { background-color: #007bff; color: white; border: none; padding: 10px 20px; font-size: 16px; border-radius: 5px; cursor: pointer; margin: 15px 0; }
        #gradesModal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); }
        .modal-content { background-color: #fefefe; margin: 8% auto; padding: 20px; border: 1px solid #888; width: 80%; max-width: 800px; border-radius: 8px; }
        .modal-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 10px; }
        .modal-header h2 { margin: 0; }
        .close-btn { color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; }
        .course-grade-summary { display: grid; grid-template-columns: 2fr 1fr 1fr 80px; gap: 15px; padding: 15px 10px; border-bottom: 1px solid #eee; align-items: center; }
        .course-name { font-weight: bold; }
        .course-last-updated { font-size: 12px; color: #888; }
        .course-final-grade { font-size: 20px; font-weight: bold; text-align: center; }
        .course-trend { font-size: 16px; text-align: center; }
        .trend-up { color: #28a745; } .trend-down { color: #dc3545; } .trend-stable { color: #6c757d; }
        .show-trend-btn { font-size: 12px; padding: 4px 8px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #efefef; }
    `);

    // --- ROUTER ---
    if (window.location.pathname.includes('/grade/report/user/index.php')) {
        runGradePredictorPage();
    } else if (window.location.pathname.includes('/my/')) {
        runDashboardPage();
    }

    // --- GRADE PREDICTOR PAGE ---
    function runGradePredictorPage() {
        let categories = {};
        let allAssignments = [];
        let courseId = new URLSearchParams(window.location.search).get('id');
        let courseName = document.querySelector('h1').textContent;

        const predictorHTML = `
            <div id="gradePredictorContainer">
                <h2>Grade Dashboard for ${courseName}</h2>
                <div class="predictor-body">
                    <div class="predictor-assignments">
                        <div id="gradeCategories"></div>
                        <div id="hypotheticalAssignments" class="grade-category"><h4>Add Future Assignments</h4></div>
                        <button id="addAssignmentBtn">+ Add Assignment</button>
                    </div>
                    <div class="predictor-sidebar">
                        <div class="predictor-module" id="gradeSummary">
                            <h4>Predicted Grade</h4>
                            <div id="predictedGrade">--%</div>
                            <div id="predictedLetterGrade"></div>
                            <div id="totalWeightInfo"></div>
                        </div>
                        <div class="predictor-module" id="categoryWeightContainer">
                             <h4>Category Weights</h4>
                             <div id="categoryWeightChartContainer"></div>
                             <ul id="chartLegend"></ul>
                        </div>
                        <div class="predictor-module" id="goalSeeker">
                            <h4>Goal Seeker</h4>
                            <p style="font-size: 13px; color: #666;">What average grade do I need on my remaining assignments to get a final grade of...</p>
                            <div class="goal-inputs">
                                <input type="number" id="targetGradeInput" placeholder="e.g., 90">
                                <input type="number" id="remainingWeightInput" placeholder="Remaining %">
                            </div>
                            <div id="goalSeekerResult"></div>
                        </div>
                         <div class="predictor-module" id="gradeAlertsContainer">
                            <h4>Suggestions & Alerts</h4>
                            <div id="gradeAlerts"></div>
                        </div>
                    </div>
                </div>
            </div>
        `;
        document.getElementById('region-main').insertAdjacentHTML('beforeend', predictorHTML);

        function parseGrades() {
            const gradeTable = document.querySelector('.user-grade');
            if (!gradeTable) return;
            categories = {};
            allAssignments = [];
            let currentCategory = 'Uncategorized';
            const rows = gradeTable.querySelectorAll('tbody > tr:not(.spacer)');

            rows.forEach(row => {
                if (row.classList.contains('category')) {
                    const catNameElem = row.querySelector('a + span');
                    if (catNameElem) currentCategory = catNameElem.textContent.trim();
                } else if (row.querySelector('.column-itemname .gradeitemheader')) {
                    const nameElem = row.querySelector('.column-itemname .gradeitemheader');
                    let weight = parseFloat(row.querySelector('.column-weight').textContent.replace('%', '')) / 100;
                    let grade = parseFloat(row.querySelector('.column-grade').textContent);
                    let maxGrade = parseFloat(row.querySelector('.column-range').textContent.split('–')[1]);

                    if (!isNaN(weight) && !isNaN(grade) && !isNaN(maxGrade) && weight > 0) {
                        const assignment = { name: nameElem.textContent.trim(), grade, maxGrade, weight, isHypothetical: false };
                        if (!categories[currentCategory]) categories[currentCategory] = [];
                        categories[currentCategory].push(assignment);
                        allAssignments.push(assignment);
                    }
                }
            });
            renderCategories();
            runAllCalculations();
        }

        function renderCategories() {
            const container = document.getElementById('gradeCategories');
            container.innerHTML = '';
            for (const catName in categories) {
                if (categories[catName].every(a => a.isHypothetical)) continue;
                const catDiv = document.createElement('div');
                catDiv.className = 'grade-category';
                catDiv.innerHTML = `<h4>${catName}</h4>`;
                categories[catName].filter(a => !a.isHypothetical).forEach(a => {
                    const row = document.createElement('div');
                    row.className = 'assignment-row';
                    row.innerHTML = `<span class="assignment-name" title="${a.name}">${a.name}</span><span><strong>${(a.weight * 100).toFixed(1)}%</strong></span><span>${a.grade.toFixed(2)}/${a.maxGrade.toFixed(2)}</span>`;
                    catDiv.appendChild(row);
                });
                container.appendChild(catDiv);
            }
        }

        function runAllCalculations() {
            const { finalGrade } = calculateGrade();
            storeGrade(finalGrade);
            runAlertsAndSuggestions();
            renderCategoryWeightChart();
            setupGoalSeeker();
        }

        function calculateGrade() {
            let totalWeightedGrade = 0;
            let totalWeight = 0;
            allAssignments.forEach(a => {
                if (a.weight > 0 && a.maxGrade > 0) {
                    totalWeightedGrade += (a.grade / a.maxGrade) * a.weight;
                    totalWeight += a.weight;
                }
            });
            const finalGrade = totalWeight > 0 ? (totalWeightedGrade / totalWeight) * 100 : 0;
            document.getElementById('predictedGrade').textContent = `${finalGrade.toFixed(2)}%`;
            document.getElementById('totalWeightInfo').textContent = `Based on ${(totalWeight * 100).toFixed(0)}% of course weight.`;
            return { finalGrade, totalWeight, totalWeightedGrade };
        }

        function storeGrade(finalGrade) {
            const data = getStoredData();
            if (!data[courseId]) data[courseId] = { name: courseName, history: [] };
            const history = data[courseId].history;
            if (history.length === 0 || history[history.length - 1].grade !== finalGrade.toFixed(2)) {
                history.push({ date: new Date().toISOString().split('T')[0], grade: finalGrade.toFixed(2) });
                if (history.length > 10) history.shift();
            }
            data[courseId].name = courseName;
            setStoredData(data);
        }

        function runAlertsAndSuggestions() {
            const container = document.getElementById('gradeAlerts');
            container.innerHTML = '';
            let hasAlerts = false;
            allAssignments.filter(a => !a.isHypothetical).forEach(a => {
                if (a.grade === 0) {
                    hasAlerts = true;
                    container.innerHTML += `<div class="alert alert-critical"><strong>CRITICAL:</strong> "${a.name}" has a score of 0. This is significantly impacting your grade.</div>`;
                }
                if ((a.grade / a.maxGrade) < 0.75 && a.weight >= 0.10) {
                     hasAlerts = true;
                     container.innerHTML += `<div class="alert alert-warning"><strong>Warning:</strong> Your score on "${a.name}" is below 75% on an item worth ${(a.weight * 100).toFixed(0)}% of your grade.</div>`;
                }
            });
            if (!hasAlerts) {
                 container.innerHTML = `<div class="alert alert-ok"><strong>All Good!</strong> No immediate issues detected. Keep up the great work!</div>`;
            }
        }

        function renderCategoryWeightChart() {
            const container = document.getElementById('categoryWeightChartContainer');
            const legend = document.getElementById('chartLegend');
            container.innerHTML = ''; legend.innerHTML = '';
            const categoryWeights = {};
            let totalChartWeight = 0;
            Object.keys(categories).forEach(catName => {
                const catWeight = categories[catName].reduce((sum, a) => sum + a.weight, 0);
                if (catWeight > 0) {
                    categoryWeights[catName] = catWeight;
                    totalChartWeight += catWeight;
                }
            });

            const colors = ['#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8', '#6f42c1'];
            let cumulativePercent = 0;
            const radius = 50;
            const circumference = 2 * Math.PI * radius;
            let svg = `<svg id="categoryWeightChart" width="120" height="120" viewBox="0 0 120 120">`;
            let colorIndex = 0;

            for (const catName in categoryWeights) {
                const percent = (categoryWeights[catName] / totalChartWeight) * 100;
                const color = colors[colorIndex % colors.length];
                const offset = circumference - (cumulativePercent / 100 * circumference);

                svg += `<circle r="${radius}" cx="60" cy="60" fill="transparent" stroke="${color}" stroke-width="20"
                        stroke-dasharray="${(percent/100)*circumference} ${circumference}"
                        transform="rotate(-90 60 60)" style="stroke-dashoffset: ${offset};"></circle>`;

                legend.innerHTML += `<li><span style="background-color: ${color};"></span>${catName} (${(percent).toFixed(0)}%)</li>`;
                cumulativePercent += percent;
                colorIndex++;
            }
            svg += `</svg>`;
            container.innerHTML = svg;
        }

        function setupGoalSeeker() {
            const targetInput = document.getElementById('targetGradeInput');
            const weightInput = document.getElementById('remainingWeightInput');
            const resultDiv = document.getElementById('goalSeekerResult');

            const calculateGoal = () => {
                const targetGrade = parseFloat(targetInput.value) / 100;
                const remainingWeight = parseFloat(weightInput.value) / 100;
                if (isNaN(targetGrade) || isNaN(remainingWeight) || remainingWeight <= 0) {
                    resultDiv.textContent = ''; return;
                }
                const { totalWeight, totalWeightedGrade } = calculateGrade();
                if (totalWeight + remainingWeight > 1.01) { // Allow for rounding errors
                    resultDiv.textContent = 'Error: Weights exceed 100%'; return;
                }
                const currentScore = totalWeightedGrade;
                const targetScore = targetGrade * (totalWeight + remainingWeight);
                const neededScore = targetScore - currentScore;
                const requiredAvg = (neededScore / remainingWeight) * 100;

                if (requiredAvg > 100) {
                    resultDiv.innerHTML = `<span style="color: #dc3545;">Need: ${requiredAvg.toFixed(2)}% (High)</span>`;
                } else if (requiredAvg < 0) {
                     resultDiv.innerHTML = `<span style="color: #28a745;">Goal is already met!</span>`;
                } else {
                    resultDiv.innerHTML = `<span style="color: #0056b3;">Need: ${requiredAvg.toFixed(2)}%</span>`;
                }
            };
            targetInput.oninput = calculateGoal;
            weightInput.oninput = calculateGoal;
        }

        document.getElementById('addAssignmentBtn').addEventListener('click', () => {
             const hypoCategory = 'Hypothetical';
             if (!categories[hypoCategory]) categories[hypoCategory] = [];
             const newAssignment = { name: 'New Assignment', grade: 0, maxGrade: 100, weight: 0.1, isHypothetical: true };
             categories[hypoCategory].push(newAssignment);
             allAssignments.push(newAssignment);
             const index = categories[hypoCategory].length - 1;

             const container = document.getElementById('hypotheticalAssignments');
             const row = document.createElement('div');
             row.className = 'assignment-row';
             // *** FIX WAS APPLIED HERE *** The last input tag was missing its closing '>'
             row.innerHTML = `<input type="text" value="${newAssignment.name}" class="hypo-name" data-index="${index}"><span><input type="number" value="${newAssignment.grade}" class="hypo-grade" data-index="${index}">/<input type="number" value="${newAssignment.maxGrade}" class="hypo-max" data-index="${index}"></span><input type="number" value="${(newAssignment.weight*100).toFixed(0)}" class="hypo-weight" data-index="${index}" placeholder="%">`;
             container.appendChild(row);

             row.querySelectorAll('input').forEach(input => input.addEventListener('input', (e) => {
                const idx = parseInt(e.target.dataset.index);
                const assignment = categories[hypoCategory][idx];
                if (e.target.classList.contains('hypo-name')) assignment.name = e.target.value;
                else if (e.target.classList.contains('hypo-grade')) assignment.grade = parseFloat(e.target.value) || 0;
                else if (e.target.classList.contains('hypo-max')) assignment.maxGrade = parseFloat(e.target.value) || 100;
                else if (e.target.classList.contains('hypo-weight')) assignment.weight = parseFloat(e.target.value) / 100 || 0;
                runAllCalculations();
             }));
             runAllCalculations();
        });

        parseGrades();
    }

    // --- DASHBOARD PAGE ---
    function runDashboardPage() {
        const dashboardHeader = document.querySelector('#page-header .page-header-headings');
        if (!dashboardHeader) return;
        const button = document.createElement('button');
        button.id = 'allGradesBtn'; button.textContent = 'View All Grades Summary';
        dashboardHeader.parentNode.insertBefore(button, dashboardHeader.nextSibling);

        const modalHTML = `<div id="gradesModal"><div class="modal-content"><div class="modal-header"><h2>All Grades Summary</h2><span class="close-btn">&times;</span></div><div id="modalBody"></div></div></div>`;
        document.body.insertAdjacentHTML('beforeend', modalHTML);

        const modal = document.getElementById('gradesModal');
        button.onclick = () => { renderModalContent(); modal.style.display = 'block'; };
        document.querySelector('.close-btn').onclick = () => modal.style.display = 'none';
        window.onclick = (event) => { if (event.target == modal) modal.style.display = 'none'; };

        function renderModalContent() {
            const modalBody = document.getElementById('modalBody');
            const data = getStoredData();
            if (Object.keys(data).length === 0) {
                modalBody.innerHTML = `<p>No grade data found. Visit a course's grade page first to populate this summary.</p>`; return;
            }
            let content = '';
            for (const courseId in data) {
                const course = data[courseId];
                if (!course.history || course.history.length === 0) continue;
                const latest = course.history[course.history.length - 1];
                let trendHTML = '<span class="trend-stable">—</span>';
                if (course.history.length > 1) {
                    const prev = course.history[course.history.length - 2];
                    const diff = parseFloat(latest.grade) - parseFloat(prev.grade);
                    if (diff > 0) trendHTML = `<span class="trend-up">▲ +${diff.toFixed(2)}%</span>`;
                    else if (diff < 0) trendHTML = `<span class="trend-down">▼ ${diff.toFixed(2)}%</span>`;
                }
                content += `
                    <div id="summary-${courseId}">
                        <div class="course-grade-summary">
                            <div><div class="course-name">${course.name}</div><div class="course-last-updated">Last calculated: ${new Date(latest.date).toLocaleDateString()}</div></div>
                            <div class="course-final-grade">${parseFloat(latest.grade).toFixed(2)}%</div>
                            <div class="course-trend">${trendHTML}</div>
                            <button class="show-trend-btn" data-courseid="${courseId}">Show Trend</button>
                        </div>
                    </div>`;
            }
            modalBody.innerHTML = content;
            document.querySelectorAll('.show-trend-btn').forEach(btn => btn.onclick = toggleTrendChart);
        }

        function toggleTrendChart(event) {
            const btn = event.target;
            const courseId = btn.dataset.courseid;
            const container = document.getElementById(`summary-${courseId}`);
            const existingChart = container.querySelector('.trend-chart-container');

            if (existingChart) {
                existingChart.remove();
                btn.textContent = 'Show Trend';
            } else {
                const data = getStoredData()[courseId];
                if (data && data.history.length > 1) {
                    const chartDiv = document.createElement('div');
                    chartDiv.className = 'trend-chart-container';
                    createTrendChart(data.history, chartDiv);
                    container.appendChild(chartDiv);
                    btn.textContent = 'Hide Trend';
                }
            }
        }

       function createTrendChart(history, container) {
            const grades = history.map(p => parseFloat(p.grade));
            const minGrade = Math.min(...grades);
            const maxGrade = Math.max(...grades);
            const gradeRange = maxGrade - minGrade;

            const w = 600, h = 150, p = 30;
            const gradeSpan = gradeRange < 10 ? 10 : gradeRange; // Min 10pt span to avoid flat lines
            const y_start = Math.floor(minGrade - (gradeSpan * 0.1));

            const getX = (i) => p + i * (w - 2*p) / (history.length - 1);
            const getY = (grade) => h - p - ((grade - y_start) / gradeSpan * (h - 2*p));

            let points = "";
            history.forEach((point, i) => {
                points += `${getX(i)},${getY(parseFloat(point.grade))} `;
            });

            let y_axis_labels = '';
            for(let i=0; i<=2; i++){
                const grade = y_start + (i/2 * gradeSpan);
                y_axis_labels += `<text x="${p-10}" y="${getY(grade)}" text-anchor="end" alignment-baseline="middle" font-size="10" fill="#666">${grade.toFixed(1)}%</text>`;
            }

            container.innerHTML = `
                <svg width="100%" viewBox="0 0 ${w} ${h}">
                    <line x1="${p}" y1="${h-p}" x2="${w-p}" y2="${h-p}" stroke="#ccc"/>
                    <line x1="${p}" y1="${p}" x2="${p}" y2="${h-p}" stroke="#ccc"/>
                    ${y_axis_labels}
                    <polyline points="${points}" fill="none" stroke="#007bff" stroke-width="2"/>
                </svg>`;
        }
    }
})();