const domain = window.location.origin; let current_page = window.location.pathname; // Canvas' "New Canvas" UI navigates client-side via history.pushState/ // replaceState without a full page reload. current_page is captured once at // document_start, so without this hook it goes stale and page-specific features // (Back to Assignment button, sequence-footer removal, profile logout button) // never activate when the user clicks into a page instead of loading it directly. function setupNavigationListener() { const update = () => { const next = window.location.pathname; if (next === current_page) return; current_page = next; // Re-run the page-scoped watchers so they evaluate against the new URL. watchSequenceFooter(); watchSubmissionPageButton(); watchProfileLogoutPageButton(); watchGradeAnalytics(); }; for (const method of ["pushState", "replaceState"]) { const orig = history[method]; history[method] = function (...args) { const ret = orig.apply(this, args); update(); return ret; }; } window.addEventListener("popstate", update); } function getCurrentCourseId() { const match = current_page.match(/^\/courses\/(\d+)(?:\/|$)/); return match ? parseInt(match[1]) : null; } function getSidebarLayoutMode() { if (current_page.match(/^\/courses\/(\d+)(?:\/|$)/)) return "course"; if (isProfilePage()) return "course"; if (current_page === "/courses" || current_page === "/courses/") return "dash"; if (current_page === "/" || current_page === "") return "dash"; return "dash"; } function isGradesPage() { return /^\/courses\/\d+\/grades(?:\/|$)/.test(current_page); } function isCoursesIndexPage() { return /^\/courses\/?$/.test(current_page); } function isGroupsIndexPage() { return /^\/groups\/?$/.test(current_page); } function isConversationsPage() { return /^\/conversations(?:\/|$)/.test(current_page); } function isAccountsPage() { return /^\/accounts(?:\/|$)/.test(current_page); } function isProfilePage() { return /^\/profile(?:\/|$)/.test(current_page); } // Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and // /courses/123/quizzes/456/take (the actual quiz). function isQuizPage() { return /^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(current_page); } function isQuizTakePage() { return /^\/courses\/\d+\/quizzes\/\d+\/take(?:\/|$)/.test(current_page); } function isQuizPreTakePage() { return isQuizPage() && !isQuizTakePage(); } // "Quiz safe mode" disables features that interfere with the default Canvas // quiz experience. Only active on quiz pages when the user has opted in. function quizSafeModeActive() { return isQuizPage() && options.quiz_safe_mode === true; } // Read the URL live: Canvas' "New Canvas" UI navigates client-side via // history.pushState/replaceState, and current_page (captured at document_start) // can be stale when the user clicks into a submission page. Reading // window.location.pathname at check time makes this correct regardless. function getSubmissionAssignmentLink() { const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/); if (!match) return null; return `${domain}/courses/${match[1]}/assignments/${match[2]}/`; } // The content container isn't always #content on every Canvas layout (submission // pages in particular may render into .ic-Layout-contentMain or #main). Match the // quiz-safe-mode banner's container finder so the button lands in the visible // content area; fall back to
so injection never silently no-ops. function findContentContainer() { return document.querySelector(".ic-Layout-contentMain") || document.getElementById("content") || document.querySelector("#main") || document.body; } let submissionPageButtonObserver = null; let submissionButtonScheduled = false; let assignmentButtonScheduled = false; let profileLogoutButtonObserver = null; let newCanvasButtonObserver = null; let sequenceFooterObserver = null; // Current user id, needed to build "Go to Grades" links on assignment pages. // The page's ENV global isn't visible to content scripts (isolated world), so // ask the Canvas API once and cache the result. // undefined = not fetched yet, null = fetch failed, number = ok. let currentUserIdCache; let currentUserIdPromise = null; function ensureCurrentUserId() { if (currentUserIdCache !== undefined) return Promise.resolve(currentUserIdCache); if (!currentUserIdPromise) { currentUserIdPromise = getData(`${domain}/api/v1/users/self`) .then(user => { currentUserIdCache = (user && user.id) || null; }) .catch(() => { currentUserIdCache = null; }) .then(() => { currentUserIdPromise = null; return currentUserIdCache; }); } return currentUserIdPromise; } // Assignment pages (/courses/123/assignments/456) link to the current user's // submission ("grades") page for that assignment. The lookahead keeps this // from matching the submission pages themselves (/.../submissions/678). function getAssignmentGradesLink() { const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)(?!\/submissions)(?:\/|$)/); if (!match || currentUserIdCache == null) return null; return `${domain}/courses/${match[1]}/assignments/${match[2]}/submissions/${currentUserIdCache}`; } function addSubmissionPageButton() { const assignmentLink = getSubmissionAssignmentLink(); if (!assignmentLink) return; // Place the button inline with the "Submission Details" heading and the // grade-values table, inside the .submission-details-header__heading-and-grades // flex row (appended so it sits to the right of the grade summary). Only inject // once that row exists; if it's not there yet the persistent MutationObserver // re-tries on the next DOM change so we never fall back to body/#content // (which would put the button at the bottom of the page). const row = document.querySelector(".submission-details-header__heading-and-grades") || document.querySelector(".submission-details-header") || document.querySelector(".submission_details"); if (!row || row.querySelector("#canvasrefined-assignment-return")) return; // Insert between the h1 heading and the grade-summary div so it reads // [Heading] [Back to Assignment] [Grade]. Falls back to appending if the // grade-summary div isn't found for some reason. const gradeSummary = row.querySelector(".submission-details-header__grade-summary"); const btn = makeElement("a", row, { id: "canvasrefined-assignment-return", className: "canvasrefined-custom-btn", href: assignmentLink, textContent: "Back to Assignment", style: "display:inline-flex;align-items:center;justify-content:center;align-self:center;margin-left:auto;margin-right:12px;padding:6px 12px;text-decoration:none;font-weight:700;color:inherit!important;", }); if (gradeSummary && gradeSummary.parentNode === row) { row.insertBefore(btn, gradeSummary); } } // Assignment pages: /courses/123/assignments/456 — add a "Go to Grades" button // to the right edge of the title row. function addAssignmentPageButton() { const link = getAssignmentGradesLink(); if (!link) return; // Place the button inside the assignment header's .title-content block, // pinned to its right edge on the title's line. .title-content is a plain // block wrapping the${c}`);
h = h.replace(/\*\*([^*]+?)\*\*/g, "$1");
h = h.replace(/~~([^~]+?)~~/g, "${q.join("`); continue; } if (/^\s*[-*+]\s+/.test(line)) { const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (!m) break; const tk = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (tk) { items.push(`
")}
${para.map(inline).join("
")}
original we (a) carve p + its descendants out of the custom rule // and (b) pin them back to the font body had BEFORE the custom rule landed — // plain `inherit` wouldn't work because p's ancestors still carry the custom // font via `*`. The pre-custom font is captured once from body's computed // style (before our
`; const toggleBtn = panel.querySelector("#canvasrefined-ga-toggle"); const imagineBtn = panel.querySelector("#canvasrefined-ga-imagine"); const fitYCheckbox = panel.querySelector("#canvasrefined-ga-fity"); const applyOpenState = () => applyGradeAnalyticsOpenState(panel); toggleBtn.addEventListener("click", () => { gaOpen = !gaOpen; setGradeAnalyticsOpenState(gaOpen); applyOpenState(); if (gaOpen && gaData) renderGradeAnalytics(); }); // "Imagine-If mode" button on the right side of the panel header. The // toggle is per-page-view only (always off on load); the active style // highlights the button while the mode is on. imagineBtn.addEventListener("click", () => { gaImagineIf = !gaImagineIf; applyGradeAnalyticsImagineState(panel); if (gaImagineIf) gaEnterImagineIf(); else gaExitImagineIf(); }); // "Fit Y axis" scales the line chart's Y axis to the data instead of a // fixed 0-100; the choice is remembered across pages via chrome.storage. fitYCheckbox.checked = gaFitY; fitYCheckbox.addEventListener("change", () => { gaFitY = fitYCheckbox.checked; setGradeAnalyticsFitY(gaFitY); if (gaData) { gaDrawLine(panel.querySelector("#canvasrefined-ga-line"), panel.querySelector("#canvasrefined-ga-line-tip")); } }); // Tab switching between the charts overview and the final calculator. panel.querySelectorAll("[data-ga-tab]").forEach(btn => { btn.addEventListener("click", () => gaSetTab(btn.dataset.gaTab)); }); // Final-grade calculator inputs persist per course; every change re-saves // and recomputes the result against the live current grade. const calcWeight = panel.querySelector("#canvasrefined-ga-calc-weight"); const calcTarget = panel.querySelector("#canvasrefined-ga-calc-target"); const calcShow = panel.querySelector("#canvasrefined-ga-calc-show"); const onCalcInput = () => { gaCalc = gaCalc || { weight: null, target: null, show: false }; const w = parseFloat(calcWeight.value); const t = parseFloat(calcTarget.value); gaCalc.weight = isFinite(w) ? Math.min(100, Math.max(0, w)) : null; gaCalc.target = isFinite(t) ? Math.max(0, t) : null; gaCalc.show = calcShow.checked; saveGaCalcSettings(); renderGaCalculator(); renderGaStats(); }; calcWeight.addEventListener("input", onCalcInput); calcTarget.addEventListener("input", onCalcInput); calcShow.addEventListener("change", onCalcInput); applyGaCalcState(panel); applyGradeAnalyticsImagineState(panel); applyOpenState(); // If the data finished loading before this panel was created (or before // the stored open state was restored), the earlier render call found no // panel — draw the charts now that it exists. if (gaOpen && gaData) renderGradeAnalytics(); // A chart skipped because its box had no size (page still laying out, // tab/panel hidden) is left at width 0; this observer redraws the moment // the boxes get real dimensions, even when no DOM mutation or window // resize follows. if (gaChartObserver) gaChartObserver.disconnect(); gaChartObserver = new ResizeObserver(() => { if (!gaOpen || !gaData) return; const pie = panel.querySelector("#canvasrefined-ga-pie"); const line = panel.querySelector("#canvasrefined-ga-line"); if ((pie && pie.width === 0) || (line && line.width === 0)) renderGradeAnalytics(); }); gaChartObserver.observe(panel.querySelector("#canvasrefined-ga-pie").parentNode); gaChartObserver.observe(panel.querySelector("#canvasrefined-ga-line").parentNode); return panel; } async function loadGradeAnalytics() { const courseId = getCurrentCourseId(); if (courseId == null) return; gaLoading = true; const status = document.getElementById("canvasrefined-ga-status"); if (status) status.textContent = "Reading grade data…"; try { // Parse the grades table the page already rendered instead of hitting // the paginated API — instant even for courses with hundreds of // assignments, and always in sync with what the page shows. const table = await gaWaitForGradesTable(); gaCourseId = courseId; gaData = computeGradeAnalyticsFromPage(table); gaLoading = false; // renderGradeAnalytics self-guards on panel existence and canvas // size; if the panel isn't ready yet, the retry in // ensureGradeAnalyticsPanel / syncGradeAnalyticsUI draws once it is. renderGradeAnalytics(); } catch (err) { logError(err); const s = document.getElementById("canvasrefined-ga-status"); if (s) s.textContent = "Grade Analytics failed to load: " + (err && err.message ? err.message : err); } finally { gaLoading = false; } } // Parses one assignment row of #grades_summary into a plain record. The row // stashes the original posted score in a hidden "original_score" span (the // "original_points" span holds points EARNED, not possible), while points // possible is only in the "/ 15" span displayed after the grade. function gaParseNum(t) { if (!t) return null; let s = String(t).replace(/\s+/g, ""); if (s === "" || !/\d/.test(s)) return null; // Normalize "1,234.5" (thousands grouping) and "9,5" (comma decimal). if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, ""); else if (/^-?\d+,\d+$/.test(s)) s = s.replace(",", "."); const v = parseFloat(s); return isFinite(v) ? v : null; } function gaParseAssignmentRow(tr) { const q = (sel) => tr.querySelector(sel); const titleLink = q(".title a"); const possibleText = q(".tooltip .grade + span")?.textContent || ""; return { title: (titleLink ? titleLink.textContent : (q("th.title")?.textContent || "")).trim(), score: gaParseNum(q(".original_score")?.textContent), points: gaParseNum(possibleText.replace(/^.*\//, "")), // Rows the page lists as unsubmitted/unposted carry no score; only // "graded" rows have one. status: (q(".submission_status")?.textContent || "").trim(), gid: (q(".assignment_group_id")?.textContent || "").trim(), due: (q("td.due")?.textContent || "").replace(/\s+/g, " ").trim(), }; } function computeGradeAnalyticsFromPage(table) { // Assignment group weights come from the "group total" summary rows // (e.g. "Summative Assessment — 86.67%, weight 85"). const groupWeight = {}; for (const tr of table.querySelectorAll("tr.group_total")) { const gid = tr.querySelector(".assignment_group_id")?.textContent.trim(); const w = parseFloat((tr.querySelector(".group_weight")?.textContent || "").trim()); if (gid) groupWeight[gid] = isFinite(w) ? w : 0; } const totalWeight = Object.values(groupWeight).reduce((s, w) => s + w, 0); // The page's own computed Total (e.g. "91.1%") — use it directly so the // "Overall grade" stat always matches the page. let pageTotal = null; const totalText = table.querySelector("tr.final_grade .grade")?.textContent || ""; const totalMatch = totalText.match(/-?\d+(?:\.\d+)?/); if (totalMatch) pageTotal = parseFloat(totalMatch[0]); // Rows are already listed in due-date order; skip the summary rows (they // carry the student_assignment class too). const rows = [...table.querySelectorAll("tr.student_assignment")] .filter(tr => !tr.classList.contains("group_total") && !tr.classList.contains("final_grade")) .map(gaParseAssignmentRow); const counts = GA_BUCKETS.map(() => 0); let ungraded = 0; const graded = []; for (const a of rows) { if (a.points == null || a.points <= 0) continue; // no points possible if (a.score == null) { ungraded++; continue; } // unposted / unsubmitted graded.push(a); const pct = (a.score / a.points) * 100; const idx = GA_BUCKETS.findIndex(b => pct >= b.min && pct < b.max); counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++; } // Running overall grade, in the page's row order, using Canvas's own // weighting algorithm (GradeCalculator): sum each group's pct × weight // over groups that have graded work, then scale up to 100% only when // those weights total less than 100 (weights over 100 are used raw and // can push the grade past 100). Verified to reproduce the Total shown // on the page. Point-based courses (no group weights) use points // earned / points possible. const running = {}; const pointsGrade = () => { let s = 0, p = 0; for (const g of Object.values(running)) { s += g.score; p += g.pts; } return p > 0 ? (s / p) * 100 : null; }; const points = graded.map(a => { const r = (running[a.gid] ||= { score: 0, pts: 0 }); r.score += a.score; r.pts += a.points; let grade = null; if (totalWeight > 0) { let weighted = 0, fullWeight = 0; for (const gid of Object.keys(running)) { const g = running[gid]; if (g.pts <= 0) continue; const w = groupWeight[gid] || 0; weighted += (g.score / g.pts) * w; fullWeight += w; } // Only zero-weighted groups have graded work — fall back to // points so the chart still has a line. grade = fullWeight > 0 ? (fullWeight < 100 ? (weighted / fullWeight) * 100 : weighted) : pointsGrade(); } else { grade = pointsGrade(); } return { title: a.title, score: a.score, points: a.points, pct: (a.score / a.points) * 100, grade, due: a.due, }; }); const pcts = graded.map(a => (a.score / a.points) * 100); // Trend: change in the running overall grade over the last 5 graded // assignments (or since the first, if fewer). Positive = climbing. let trend = null; if (points.length >= 2) { const from = points[Math.max(0, points.length - 1 - 5)].grade; const to = points[points.length - 1].grade; if (from != null && to != null) trend = to - from; } return { counts, ungraded, graded: graded.length, avg: pcts.length ? pcts.reduce((s, p) => s + p, 0) / pcts.length : null, current: pageTotal != null ? pageTotal : (points.length ? points[points.length - 1].grade : null), trend, points, }; } // Stat cards row on the Overview tab. The optional "Grade goal" card appears // when the final calculator's "show" checkbox is on; it recomputes against // the live current grade so it stays accurate as new grades come in. function renderGaStats() { const panel = document.getElementById("canvasrefined-grade-analytics"); if (!panel || !gaData) return; const stats = panel.querySelector("#canvasrefined-ga-stats"); if (!stats) return; stats.style.display = "flex"; const stat = (cap, val, color) => `