diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 4bad566..29d47e8 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1,311 +1,320 @@
-{
- "Canvas_Refined": {
- "message": "Canvas Refined"
- },
- "search_features": {
- "message": "Search features..."
- },
- "updates": {
- "message": "Updates"
- },
- "dark_mode": {
- "message": "Dark mode"
- },
- "automatic_dark_mode": {
- "message": "Scheduled Dark Mode"
- },
- "auto_start_time": {
- "message": "Start Time"
- },
- "auto_end_time": {
- "message": "End Time"
- },
- "edit_dark_mode": {
- "message": "Dark Mode"
- },
- "enter_url": {
- "message": "Your Canvas url:"
- },
- "card_grades": {
- "message": "Card Grades"
- },
- "gradient_cards": {
- "message": "Gradient Cards"
- },
- "condensed_cards": {
- "message": "Condensed Cards"
- },
- "disable_color": {
- "message": "Disable Color Overlay"
- },
- "card_assignments": {
- "message": "Card Assignments"
- },
- "card_customization": {
- "message": "Cards"
- },
- "styles": {
- "message": "Styles"
- },
- "useddmm": {
- "message": "Use dd/mm"
- },
- "better_todo": {
- "message": "Better todo list"
- },
- "better_sidebar": {
- "message": "Better Sidebar"
- },
- "quiz_safe_mode": {
- "message": "Quiz Safe Mode"
- },
- "quiz_safe_mode_desc": {
- "message": "Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience"
- },
- "opacity_options": {
- "message": "Opacity & Blur"
- },
- "opacity_only_background": {
- "message": "Only available with a custom background"
- },
- "card_transparency": {
- "message": "Card transparency"
- },
- "background_opacity": {
- "message": "Background opacity"
- },
- "bg_blur": {
- "message": "Background blur"
- },
- "sidebar_opacity": {
- "message": "Sidebar opacity"
- },
- "sidebar_blur": {
- "message": "Sidebar blur"
- },
- "card_opacity": {
- "message": "Card opacity"
- },
- "card_blur": {
- "message": "Card blur"
- },
- "sidebar_opacity_auto": {
- "message": "Applies to the Better Sidebar when it's on, otherwise the normal sidebar."
- },
- "reset_default": {
- "message": "Reset to default"
- },
- "max_items": {
- "message": "Max Items: "
- },
- "hover_preview": {
- "message": "Previews on hover"
- },
- "hide_completed": {
- "message": "Hide Completed"
- },
- "24hrformat": {
- "message": "Use 24 hr Format"
- },
- "dashboard_notes": {
- "message": "Dashboard Notes"
- },
- "gpa_calculator": {
- "message": "GPA Calculator"
- },
- "gpa_settings": {
- "message": "GPA Settings"
- },
- "grade_hover": {
- "message": "Show Only On Hover"
- },
- "due": {
- "message": "Due"
- },
- "card_colors": {
- "message": "Card Colors"
- },
- "back": {
- "message": "Back"
- },
- "more_options": {
- "message": "More Options"
- },
- "manual_customization": {
- "message": "Manual Customization"
- },
- "background_colors": {
- "message": "Background"
- },
- "text_colors": {
- "message": "Text"
- },
- "full_width": {
- "message": "Full Width Fix"
- },
- "report_issue": {
- "message": "Report Issue"
- },
- "themes": {
- "message": "Themes"
- },
- "move_top": {
- "message": "Move To Top"
- },
- "show_past_due": {
- "message": "Show Past Due"
- },
- "todo_separate_scrollbar": {
- "message": "Separate Scrollbar"
- },
- "todo_full_height": {
- "message": "Force Full Height"
- },
- "todo_alternate_colors": {
- "message": "Alternate colors"
- },
- "relative_due_dates": {
- "message": "Relative Due Dates"
- },
- "equal_height_cards": {
- "message": "Equal Height"
- },
- "enter_multiple": {
- "message": "(Enter Multiple With A Comma)"
- },
- "todo_remind": {
- "message": "Todo Reminders"
- },
- "remlogo": {
- "message": "Remove Sidebar Logo"
- },
- "hide_feedback": {
- "message": "Hide Recent Feedback"
- },
- "tab_icons": {
- "message": "Color Coded Tab Icons"
- },
- "todo_hide_feedback": {
- "message": "Hide Recent Feedback"
- },
- "gpa_calc_cumulative": {
- "message": "Show Cumulative GPA"
- },
- "gpa_calc_weighted": {
- "message": "Show Weighted GPA"
- },
- "device_dark": {
- "message": "Use Device Dark Mode Settings"
- },
- "presets": {
- "message": "Presets"
- },
- "dark_lighter": {
- "message": "Lighter"
- },
- "dark_light": {
- "message": "Light"
- },
- "dark_default": {
- "message": "Default"
- },
- "dark_dark": {
- "message": "Dark"
- },
- "dark_darker": {
- "message": "Darker"
- },
- "dark_mint": {
- "message": "Mint"
- },
- "dark_blue": {
- "message": "Blue Tint"
- },
- "dark_burn": {
- "message": "Eyeball Burn"
- },
- "dark_unicorn": {
- "message": "Unicorn"
- },
- "dark_lightmode": {
- "message": "Lightmode"
- },
- "dark_catppuccin": {
- "message": "Catppuccin\n By Angel"
- },
- "dark_sage": {
- "message": "Sabia\n By LivDoesDevStuf"
- },
- "dark_pink": {
- "message": "Pink\n By Sayde"
- },
- "dark_coral": {
- "message": "Coral\n By Superchido"
- },
- "dp_sidebar_text": {
- "message": "Sidebar Text"
- },
- "dp_sidebar": {
- "message": "Sidebar"
- },
- "radio_sidebar_solid": {
- "message": "Solid"
- },
- "radio_sidebar_gradient": {
- "message": "Gradient"
- },
- "radio_sidebar_image": {
- "message": "Image"
- },
- "dp_background_0": {
- "message": "Background Main"
- },
- "dp_background_1": {
- "message": "Background Alt"
- },
- "dp_background_2": {
- "message": "Background Other"
- },
- "dp_borders": {
- "message": "Borders"
- },
- "dp_links": {
- "message": "Links"
- },
- "dp_text_0": {
- "message": "Text Main"
- },
- "dp_text_1": {
- "message": "Text Other"
- },
- "dp_text_2": {
- "message": "Text Alt"
- },
- "dark_mode_fixer": {
- "message": "Dark Mode Fixer (Beta)"
- },
- "dark_mode_fixer_text": {
- "message": "This Will Try To Fix Any Dark Mode Gaps On The Current Page."
- },
- "fix_dm_btn": {
- "message": "Go"
- },
- "fix_dm_text": {
- "message": "The Fixer Is Automatically Running On These Pages:"
- },
- "hide_links": {
- "message": "Hide links"
- },
- "show_links": {
- "message": "Show links"
- },
- "center_cards": {
- "message": "Center Cards"
- },
- "hide_new_canvas": {
- "message": "Hide New Dashboard"
- },
- "global_search": {
- "message": "Global Canvas Search"
- }
+{
+ "Canvas_Refined": {
+ "message": "Canvas Refined"
+ },
+ "search_features": {
+ "message": "Search features..."
+ },
+ "updates": {
+ "message": "Updates"
+ },
+ "dark_mode": {
+ "message": "Dark mode"
+ },
+ "automatic_dark_mode": {
+ "message": "Scheduled Dark Mode"
+ },
+ "auto_start_time": {
+ "message": "Start Time"
+ },
+ "auto_end_time": {
+ "message": "End Time"
+ },
+ "edit_dark_mode": {
+ "message": "Dark Mode"
+ },
+ "enter_url": {
+ "message": "Your Canvas url:"
+ },
+ "card_grades": {
+ "message": "Card Grades"
+ },
+ "gradient_cards": {
+ "message": "Gradient Cards"
+ },
+ "condensed_cards": {
+ "message": "Condensed Cards"
+ },
+ "disable_color": {
+ "message": "Disable Color Overlay"
+ },
+ "card_assignments": {
+ "message": "Card Assignments"
+ },
+ "card_customization": {
+ "message": "Cards"
+ },
+ "styles": {
+ "message": "Styles"
+ },
+ "useddmm": {
+ "message": "Use dd/mm"
+ },
+ "better_todo": {
+ "message": "Better todo list"
+ },
+ "better_sidebar": {
+ "message": "Better Sidebar"
+ },
+ "quiz_safe_mode": {
+ "message": "Quiz Safe Mode"
+ },
+ "quiz_safe_mode_desc": {
+ "message": "Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience"
+ },
+ "opacity_options": {
+ "message": "Opacity & Blur"
+ },
+ "opacity_only_background": {
+ "message": "Only available with a custom background"
+ },
+ "card_transparency": {
+ "message": "Card transparency"
+ },
+ "background_opacity": {
+ "message": "Background opacity"
+ },
+ "bg_blur": {
+ "message": "Background blur"
+ },
+ "sidebar_opacity": {
+ "message": "Sidebar opacity"
+ },
+ "sidebar_blur": {
+ "message": "Sidebar blur"
+ },
+ "card_opacity": {
+ "message": "Card opacity"
+ },
+ "card_blur": {
+ "message": "Card blur"
+ },
+ "sidebar_opacity_auto": {
+ "message": "Applies to the Better Sidebar when it's on, otherwise the normal sidebar."
+ },
+ "reset_default": {
+ "message": "Reset to default"
+ },
+ "max_items": {
+ "message": "Max Items: "
+ },
+ "hover_preview": {
+ "message": "Previews on hover"
+ },
+ "hide_completed": {
+ "message": "Hide Completed"
+ },
+ "24hrformat": {
+ "message": "Use 24 hr Format"
+ },
+ "dashboard_notes": {
+ "message": "Dashboard Notes"
+ },
+ "gpa_calculator": {
+ "message": "GPA Calculator"
+ },
+ "gpa_settings": {
+ "message": "GPA Settings"
+ },
+ "grade_hover": {
+ "message": "Show Only On Hover"
+ },
+ "due": {
+ "message": "Due"
+ },
+ "card_colors": {
+ "message": "Card Colors"
+ },
+ "back": {
+ "message": "Back"
+ },
+ "more_options": {
+ "message": "More Options"
+ },
+ "manual_customization": {
+ "message": "Manual Customization"
+ },
+ "background_colors": {
+ "message": "Background"
+ },
+ "text_colors": {
+ "message": "Text"
+ },
+ "full_width": {
+ "message": "Full Width Fix"
+ },
+ "report_issue": {
+ "message": "Report Issue"
+ },
+ "themes": {
+ "message": "Themes"
+ },
+ "move_top": {
+ "message": "Move To Top"
+ },
+ "show_past_due": {
+ "message": "Show Past Due"
+ },
+ "todo_separate_scrollbar": {
+ "message": "Separate Scrollbar"
+ },
+ "todo_full_height": {
+ "message": "Force Full Height"
+ },
+ "todo_alternate_colors": {
+ "message": "Alternate colors"
+ },
+ "relative_due_dates": {
+ "message": "Relative Due Dates"
+ },
+ "equal_height_cards": {
+ "message": "Equal Height"
+ },
+ "enter_multiple": {
+ "message": "(Enter Multiple With A Comma)"
+ },
+ "todo_remind": {
+ "message": "Todo Reminders"
+ },
+ "remlogo": {
+ "message": "Remove Sidebar Logo"
+ },
+ "hide_feedback": {
+ "message": "Hide Recent Feedback"
+ },
+ "tab_icons": {
+ "message": "Color Coded Tab Icons"
+ },
+ "todo_hide_feedback": {
+ "message": "Hide Recent Feedback"
+ },
+ "gpa_calc_cumulative": {
+ "message": "Show Cumulative GPA"
+ },
+ "gpa_calc_weighted": {
+ "message": "Show Weighted GPA"
+ },
+ "device_dark": {
+ "message": "Use Device Dark Mode Settings"
+ },
+ "presets": {
+ "message": "Presets"
+ },
+ "dark_lighter": {
+ "message": "Lighter"
+ },
+ "dark_light": {
+ "message": "Light"
+ },
+ "dark_default": {
+ "message": "Default"
+ },
+ "dark_dark": {
+ "message": "Dark"
+ },
+ "dark_darker": {
+ "message": "Darker"
+ },
+ "dark_mint": {
+ "message": "Mint"
+ },
+ "dark_blue": {
+ "message": "Blue Tint"
+ },
+ "dark_burn": {
+ "message": "Eyeball Burn"
+ },
+ "dark_unicorn": {
+ "message": "Unicorn"
+ },
+ "dark_lightmode": {
+ "message": "Lightmode"
+ },
+ "dark_catppuccin": {
+ "message": "Catppuccin\n By Angel"
+ },
+ "dark_sage": {
+ "message": "Sabia\n By LivDoesDevStuf"
+ },
+ "dark_pink": {
+ "message": "Pink\n By Sayde"
+ },
+ "dark_coral": {
+ "message": "Coral\n By Superchido"
+ },
+ "dp_sidebar_text": {
+ "message": "Sidebar Text"
+ },
+ "dp_sidebar": {
+ "message": "Sidebar"
+ },
+ "radio_sidebar_solid": {
+ "message": "Solid"
+ },
+ "radio_sidebar_gradient": {
+ "message": "Gradient"
+ },
+ "radio_sidebar_image": {
+ "message": "Image"
+ },
+ "dp_background_0": {
+ "message": "Background Main"
+ },
+ "dp_background_1": {
+ "message": "Background Alt"
+ },
+ "dp_background_2": {
+ "message": "Background Other"
+ },
+ "dp_borders": {
+ "message": "Borders"
+ },
+ "dp_links": {
+ "message": "Links"
+ },
+ "dp_text_0": {
+ "message": "Text Main"
+ },
+ "dp_text_1": {
+ "message": "Text Other"
+ },
+ "dp_text_2": {
+ "message": "Text Alt"
+ },
+ "dark_mode_fixer": {
+ "message": "Dark Mode Fixer (Beta)"
+ },
+ "dark_mode_fixer_text": {
+ "message": "This Will Try To Fix Any Dark Mode Gaps On The Current Page."
+ },
+ "fix_dm_btn": {
+ "message": "Go"
+ },
+ "fix_dm_text": {
+ "message": "The Fixer Is Automatically Running On These Pages:"
+ },
+ "hide_links": {
+ "message": "Hide links"
+ },
+ "show_links": {
+ "message": "Show links"
+ },
+ "center_cards": {
+ "message": "Center Cards"
+ },
+ "hide_new_canvas": {
+ "message": "Hide New Dashboard"
+ },
+ "hide_sequence_footer": {
+ "message": "Hide Next/Previous Buttons"
+ },
+ "global_search": {
+ "message": "Global Canvas Search"
+ },
+ "grade_analytics": {
+ "message": "Grade Analytics"
+ },
+ "grade_analytics_desc": {
+ "message": "Adds an Analytics toggle to the left side of course grade pages with a score-distribution chart and your overall grade over time."
+ }
}
\ No newline at end of file
diff --git a/css/content.css b/css/content.css
index 2967250..90474a4 100644
--- a/css/content.css
+++ b/css/content.css
@@ -365,3 +365,4 @@
border: 1px solid var(--bcborders, #c7cdd1); border-bottom-width: 2px; border-radius: 4px;
padding: 0 4px; color: var(--bctext-1, #5b6770);
}
+
diff --git a/css/popup.css b/css/popup.css
index 547684e..de94fb4 100644
--- a/css/popup.css
+++ b/css/popup.css
@@ -25,6 +25,7 @@ h2 {font-size:24px;}
#numAssignmentsSlider::-webkit-slider-thumb, #numTodoItemsSlider::-webkit-slider-thumb, #sidebarScaleSlider::-webkit-slider-thumb, #customBackgroundScale::-webkit-slider-thumb, #bgOpacitySlider::-webkit-slider-thumb, #sidebarOpacitySlider::-webkit-slider-thumb, #bgBlurSlider::-webkit-slider-thumb, #sidebarBlurSlider::-webkit-slider-thumb, #cardOpacitySlider::-webkit-slider-thumb, #cardBlurSlider::-webkit-slider-thumb {-webkit-appearance: none; appearance: none;height: 18px; width: 18px;background: #727272;cursor: pointer;border-radius:30px;}
.option-container {font-size: 14px; margin-top: 8px;background: var(--containerbg);padding: 18px;border-radius: 14px;box-sizing:border-box}
.options .option-container {padding: 14px;}
+.options .option-container > .option + .option {margin-top: 10px;}
a.option-container {display: block;color:#5ca5f6;font-size: 14px;}
a.option-container:hover {background: #333}
#customDomain {font-size: 12px; background: var(--inputbg); border: none;padding:8px;color: #e2e2e2;margin-top: 6px;border-radius: 7px; font-family: inherit;}
diff --git a/html/popup.html b/html/popup.html
index f56e6b2..b5166f0 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -76,7 +76,7 @@
Dark mode
-
-
+
diff --git a/js/background.js b/js/background.js
index f303b54..2029a6f 100644
--- a/js/background.js
+++ b/js/background.js
@@ -75,6 +75,7 @@ chrome.runtime.onInstalled.addListener(function () {
"relative_dues": false,
"equal_height_cards": false,
"hide_new_canvas": true,
+ "hide_sequence_footer": false,
"quiz_safe_mode": false,
"dark_mode_fix": [],
"assignment_states": {},
@@ -116,6 +117,7 @@ chrome.runtime.onInstalled.addListener(function () {
"bg_blur": 8,
"sidebar_blur": 0,
"global_search": false,
+ "grade_analytics": true,
}
};
diff --git a/js/content.js b/js/content.js
index 962d3a3..c1a541d 100644
--- a/js/content.js
+++ b/js/content.js
@@ -15,6 +15,7 @@ function setupNavigationListener() {
watchSequenceFooter();
watchSubmissionPageButton();
watchProfileLogoutPageButton();
+ watchGradeAnalytics();
};
for (const method of ["pushState", "replaceState"]) {
const orig = history[method];
@@ -106,6 +107,7 @@ let submissionPageButtonObserver = null;
let submissionButtonScheduled = false;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
+let sequenceFooterObserver = null;
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
@@ -211,6 +213,7 @@ function isAssignmentPage() {
}
function removeSequenceFooter() {
+ if (options.hide_sequence_footer !== true) return false;
if (!isAssignmentPage()) return false;
const sequenceFooter = document.getElementById("sequence_footer");
if (!sequenceFooter) return false;
@@ -218,11 +221,39 @@ function removeSequenceFooter() {
return true;
}
+// CSS-based hiding is the primary mechanism: the style element persists across
+// Canvas re-renders and full reloads, so the footer can never flash back after
+// the JS observer has removed it (or timed out) once.
+function applyHideSequenceFooter() {
+ let style = document.getElementById("canvasrefined-hide-sequence-footer");
+ if (options.hide_sequence_footer === true) {
+ if (!style) {
+ style = document.createElement("style");
+ style.id = "canvasrefined-hide-sequence-footer";
+ style.textContent = "#sequence_footer{display:none!important}";
+ (document.head || document.documentElement).appendChild(style);
+ }
+ } else if (style) {
+ style.remove();
+ }
+}
+
function watchSequenceFooter() {
+ applyHideSequenceFooter();
+ if (options.hide_sequence_footer !== true) {
+ if (sequenceFooterObserver) {
+ sequenceFooterObserver.disconnect();
+ sequenceFooterObserver = null;
+ }
+ return;
+ }
if (!isAssignmentPage()) return;
if (removeSequenceFooter()) return;
if (sequenceFooterObserver) return;
+ // The observer strips the footer from the DOM (no leftover gap), and
+ // disconnects once removed — after that (or after the 10s timeout below)
+ // the CSS rule above is what keeps it hidden across Canvas re-renders.
sequenceFooterObserver = new MutationObserver(() => {
if (removeSequenceFooter() && sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
@@ -682,6 +713,7 @@ function startExtension() {
ensureBetterSidebar();
watchSequenceFooter();
watchProfileLogoutPageButton();
+ watchGradeAnalytics();
setupQuizSafeModeBanner();
@@ -825,6 +857,9 @@ function applyOptionsChanges(changes) {
case "hide_new_canvas":
watchNewCanvasButton();
break;
+ case "hide_sequence_footer":
+ watchSequenceFooter();
+ break;
case "global_search":
if (options.global_search) {
setupGlobalSearch();
@@ -918,6 +953,9 @@ function applyOptionsChanges(changes) {
resetBetterSidebarLayout();
}
break;
+ case "grade_analytics":
+ watchGradeAnalytics();
+ break;
case "quiz_safe_mode":
// Toggling safe mode changes which features run on quiz pages; reload
// so the gating is applied cleanly.
@@ -6274,6 +6312,621 @@ function getAssignments() {
}
}
+// ===================== Grade Analytics =====================
+// On course grades pages, adds an "Analytics" toggle on the left side (in the
+// Better Sidebar when enabled, otherwise in the native course nav) that shows
+// a panel with a score-distribution doughnut and an overall-grade-over-time
+// line chart. Data comes from the Canvas API with the user's session, so it
+// matches the numbers on the page. Charts are hand-drawn on
so the
+// extension needs no CDN/library and no chart library dependency.
+
+const GA_BUCKETS = [
+ { label: "90+", min: 90, max: Infinity, color: "#16a34a" },
+ { label: "80-89", min: 80, max: 90, color: "#4ade80" },
+ { label: "70-79", min: 70, max: 80, color: "#a3e635" },
+ { label: "60-69", min: 60, max: 70, color: "#facc15" },
+ { label: "50-59", min: 50, max: 60, color: "#fb923c" },
+ { label: "40-49", min: 40, max: 50, color: "#f87171" },
+ { label: "30-39", min: 30, max: 40, color: "#ef4444" },
+ { label: "20-29", min: 20, max: 30, color: "#dc2626" },
+ { label: "10-19", min: 10, max: 20, color: "#b91c1c" },
+ { label: "0-9", min: 0, max: 10, color: "#7f1d1d" },
+];
+const GA_UNGRADED_COLOR = "#6b7280";
+const GA_OPEN_KEY = "grade_analytics_open";
+
+async function getGradeAnalyticsOpenState() {
+ const result = await chrome.storage.local.get(GA_OPEN_KEY);
+ return result[GA_OPEN_KEY] ?? true;
+}
+
+function setGradeAnalyticsOpenState(open) {
+ chrome.storage.local.set({ [GA_OPEN_KEY]: open });
+}
+
+const GA_FIT_Y_KEY = "grade_analytics_fit_y";
+
+async function getGradeAnalyticsFitY() {
+ const result = await chrome.storage.local.get(GA_FIT_Y_KEY);
+ return result[GA_FIT_Y_KEY] ?? false;
+}
+
+function setGradeAnalyticsFitY(fit) {
+ chrome.storage.local.set({ [GA_FIT_Y_KEY]: fit });
+}
+
+let gaObserver = null;
+let gaOpen = false; // panel open on this page view
+let gaFitY = false; // scale the line chart Y axis to fit the data
+let gaCourseId = null; // course whose data is cached
+let gaData = null; // computed data for the current course
+let gaLoading = false;
+
+function gradeAnalyticsActive() {
+ return options.grade_analytics === true && isGradesPage() && !quizSafeModeActive();
+}
+
+// Paginated Canvas API GET using the user's session. Unwraps Firefox Xray
+// proxies like getData() does. The first page is fetched serially to learn the
+// total page count from its Link header; every remaining page is then fetched
+// concurrently, so courses with many assignments don't pay one round trip per
+// page.
+async function gaFetchAll(path) {
+ const fetchPage = async (url) => {
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
+ if (!res.ok) throw new Error("Canvas API " + res.status + " for " + url.pathname);
+ return { data: JSON.parse(JSON.stringify(await res.json())), link: res.headers.get("Link") || "" };
+ };
+ const firstUrl = new URL(path, domain);
+ firstUrl.searchParams.set("per_page", "100");
+ const { data: first, link } = await fetchPage(firstUrl);
+ const out = Array.isArray(first) ? first.slice() : [];
+ const lastMatch = link.match(/<([^>]+)>;\s*rel="last"/);
+ if (lastMatch) {
+ const lastPage = parseInt(new URL(lastMatch[1]).searchParams.get("page") || "1", 10);
+ if (lastPage > 1) {
+ const pageUrls = [];
+ for (let p = 2; p <= lastPage; p++) {
+ const u = new URL(path, domain);
+ u.searchParams.set("per_page", "100");
+ u.searchParams.set("page", String(p));
+ pageUrls.push(u);
+ }
+ const rest = await Promise.all(pageUrls.map(u => fetchPage(u).then(r => r.data)));
+ for (const page of rest) if (Array.isArray(page)) out.push(...page);
+ }
+ }
+ return out;
+}
+
+// Entry point: called at init, on SPA navigation, and when the option changes.
+function watchGradeAnalytics() {
+ if (!gradeAnalyticsActive()) {
+ removeGradeAnalyticsPanel();
+ if (gaObserver) { gaObserver.disconnect(); gaObserver = null; }
+ return;
+ }
+ // SPA navigation between courses: drop cached data so the panel never
+ // shows the previous course's charts.
+ const courseId = getCurrentCourseId();
+ if (gaCourseId !== null && gaCourseId !== courseId) {
+ gaData = null;
+ gaCourseId = null;
+ removeGradeAnalyticsPanel();
+ }
+ if (!gaObserver) {
+ // Canvas re-renders the left nav and content area during SPA
+ // navigation; the observer keeps the panel placed.
+ gaObserver = new MutationObserver(() => scheduleGradeAnalyticsSync());
+ gaObserver.observe(document.documentElement, { childList: true, subtree: true });
+ }
+ scheduleGradeAnalyticsSync();
+ // Restore the open/closed state and Y-axis preference the user last
+ // chose, then inject the panel below the Print Grades header.
+ Promise.all([getGradeAnalyticsOpenState(), getGradeAnalyticsFitY()]).then(([open, fit]) => {
+ gaOpen = open;
+ gaFitY = fit;
+ ensureGradeAnalyticsPanel();
+ if (gaOpen && gaData) renderGradeAnalytics();
+ });
+ if (!gaData && !gaLoading) loadGradeAnalytics();
+}
+
+let gaSyncRaf = null;
+function scheduleGradeAnalyticsSync() {
+ if (gaSyncRaf) return;
+ gaSyncRaf = requestAnimationFrame(() => {
+ gaSyncRaf = null;
+ syncGradeAnalyticsUI();
+ });
+}
+
+function syncGradeAnalyticsUI() {
+ if (!gradeAnalyticsActive()) return;
+ const panel = ensureGradeAnalyticsPanel();
+ if (!panel) return;
+ // Self-heal: the one-shot render after data loads can be a no-op when the
+ // panel was created before Canvas finished laying out the page (zero-size
+ // canvases) or while the body was still hidden. gaSetupCanvas leaves the
+ // canvas backing store at width 0 in that case, so a 0-width canvas means
+ // "never drawn" — redraw now that layout is real.
+ if (gaOpen && gaData) {
+ const pie = panel.querySelector("#canvasrefined-ga-pie");
+ const line = panel.querySelector("#canvasrefined-ga-line");
+ if ((pie && pie.width === 0) || (line && line.width === 0)) {
+ renderGradeAnalytics();
+ }
+ }
+}
+
+function removeGradeAnalyticsPanel() {
+ gaOpen = false;
+ document.getElementById("canvasrefined-grade-analytics")?.remove();
+}
+
+// Applies the in-memory open/closed state (restored from storage) to the panel
+// DOM. Called both at panel creation and whenever an already-attached panel
+// is reused, so a stored preference is never lost to a creation race (the DOM
+// observer can build the panel before the storage read resolves).
+function applyGradeAnalyticsOpenState(panel) {
+ const body = panel.querySelector("#canvasrefined-ga-body");
+ const btn = panel.querySelector("#canvasrefined-ga-toggle");
+ if (!body || !btn) return;
+ body.style.display = gaOpen ? "" : "none";
+ const svg = btn.querySelector("svg");
+ if (svg) svg.style.transform = gaOpen ? "rotate(180deg)" : "rotate(0deg)";
+ btn.setAttribute("aria-expanded", String(gaOpen));
+}
+
+// Panel is injected directly below the "Print Grades" action header on the
+// grades page. Returns null (and retries via the DOM observer) if the anchor
+// hasn't rendered yet.
+function ensureGradeAnalyticsPanel() {
+ let panel = document.getElementById("canvasrefined-grade-analytics");
+ const anchor = document.getElementById("print-grades-container");
+ if (panel && panel.isConnected) {
+ // Canvas re-renders can shift the anchor or our position; keep the
+ // panel directly after #print-grades-container at all times.
+ if (anchor && panel.previousElementSibling !== anchor) {
+ anchor.insertAdjacentElement("afterend", panel);
+ }
+ // Re-apply the open/closed state in case it was restored from storage
+ // after this panel was first created.
+ applyGradeAnalyticsOpenState(panel);
+ return panel;
+ }
+ const container = anchor || findContentContainer();
+ if (!container) return null;
+ panel = document.createElement("div");
+ panel.id = "canvasrefined-grade-analytics";
+ if (anchor) {
+ anchor.insertAdjacentElement("afterend", panel);
+ } else {
+ // Fallback: top of the content container until the anchor renders.
+ container.insertBefore(panel, container.firstChild);
+ }
+ panel.style.cssText = `margin:18px 0;padding:16px;border:1px solid color-mix(in srgb, var(--bcborders) 75%, transparent);border-radius:10px;background-color:var(--bcbackground-0);color:var(--bctext-0);font-family:"Lato","Helvetica Neue",Helvetica,Arial,sans-serif;box-sizing:border-box;`;
+ panel.innerHTML = `
+
+
+
Loading grade data…
+
+
+
+
Score distribution (graded assignments)
+
+
+
+
+
Overall grade over time
+ Fit Y axis
+
+
+
+
+
+ `;
+ const toggleBtn = panel.querySelector("#canvasrefined-ga-toggle");
+ const fitYCheckbox = panel.querySelector("#canvasrefined-ga-fity");
+ const applyOpenState = () => applyGradeAnalyticsOpenState(panel);
+ toggleBtn.addEventListener("click", () => {
+ gaOpen = !gaOpen;
+ setGradeAnalyticsOpenState(gaOpen);
+ applyOpenState();
+ if (gaOpen && gaData) renderGradeAnalytics();
+ });
+ // "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"));
+ }
+ });
+ 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();
+ 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 = "Loading grade data…";
+ try {
+ const [assignments, groups] = await Promise.all([
+ gaFetchAll(`/api/v1/courses/${courseId}/assignments?include[]=submission&order_by=due_at`),
+ gaFetchAll(`/api/v1/courses/${courseId}/assignment_groups`),
+ ]);
+ gaCourseId = courseId;
+ gaData = computeGradeAnalytics(assignments, groups);
+ gaLoading = false;
+ // renderGradeAnalytics self-guards on panel existence and canvas
+ // size; if the panel isn't ready yet, the retry below in
+ // ensureGradeAnalyticsPanel 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;
+ }
+}
+
+function gaIsGraded(a) {
+ return a.published !== false && a.points_possible > 0 &&
+ (a.submission_types || []).indexOf("not_graded") === -1 &&
+ a.submission && a.submission.score != null;
+}
+
+function gaIsUngraded(a) {
+ // Graded-type assignment with points possible but no score yet.
+ if (a.published === false || a.points_possible <= 0) return false;
+ if ((a.submission_types || []).includes("not_graded")) return false;
+ return !(a.submission && a.submission.score != null);
+}
+
+function computeGradeAnalytics(assignments, groups) {
+ const counts = GA_BUCKETS.map(() => 0);
+ let ungraded = 0, graded = 0;
+ for (const a of assignments) {
+ if (gaIsUngraded(a)) { ungraded++; continue; }
+ if (!gaIsGraded(a)) continue;
+ const pct = (a.submission.score / a.points_possible) * 100;
+ const idx = GA_BUCKETS.findIndex(b => pct >= b.min && pct < b.max);
+ counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++;
+ }
+
+ const groupName = {};
+ const weight = {};
+ for (const g of groups) {
+ groupName[g.id] = g.name;
+ weight[g.id] = g.group_weight || 0;
+ }
+ const totalWeight = Object.values(weight).reduce((s, w) => s + w, 0);
+
+ // Running weighted grade in due-date order, exactly like the user sees it
+ // accumulate over the term.
+ const timeline = assignments
+ .filter(gaIsGraded)
+ .sort((x, y) => new Date(x.due_at || 0) - new Date(y.due_at || 0));
+ const running = {};
+ const points = timeline.map(a => {
+ const r = (running[a.assignment_group_id] ||= { score: 0, pts: 0 });
+ r.score += a.submission.score;
+ r.pts += a.points_possible;
+ let weighted = 0, used = 0;
+ for (const gid of Object.keys(running)) {
+ const g = running[gid];
+ if (g.pts <= 0) continue;
+ if (totalWeight > 0) {
+ weighted += (g.score / g.pts) * (weight[gid] || 0);
+ used += (weight[gid] || 0);
+ } else {
+ weighted += g.score;
+ used += g.pts;
+ }
+ }
+ const grade = used > 0 ? (weighted / used) * 100 : null;
+ const pct = (a.submission.score / a.points_possible) * 100;
+ return {
+ title: a.name,
+ score: a.submission.score,
+ points: a.points_possible,
+ pct,
+ grade,
+ group: groupName[a.assignment_group_id] || "",
+ due: a.due_at ? new Date(a.due_at).toLocaleDateString() : "",
+ };
+ });
+
+ const pcts = timeline.map(a => (a.submission.score / a.points_possible) * 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: pcts.length,
+ avg: pcts.length ? pcts.reduce((s, p) => s + p, 0) / pcts.length : null,
+ current: points.length ? points[points.length - 1].grade : null,
+ trend,
+ points,
+ };
+}
+
+function renderGradeAnalytics() {
+ const panel = document.getElementById("canvasrefined-grade-analytics");
+ if (!panel || !gaData) return;
+ const status = panel.querySelector("#canvasrefined-ga-status");
+ if (status) status.style.display = "none";
+
+ const stats = panel.querySelector("#canvasrefined-ga-stats");
+ stats.style.display = "flex";
+ const stat = (cap, val, color) =>
+ ``;
+ // Trend card: arrow + colored delta of the overall grade over the last 5
+ // graded assignments (green climbing, red falling, grey steady).
+ let trendVal = "-", trendColor = "var(--bctext-0)";
+ if (gaData.trend != null) {
+ if (gaData.trend > 0.05) { trendVal = "\u25B2 +" + gaData.trend.toFixed(1) + "%"; trendColor = "#16a34a"; }
+ else if (gaData.trend < -0.05) { trendVal = "\u25BC " + gaData.trend.toFixed(1) + "%"; trendColor = "#dc2626"; }
+ else { trendVal = "\u25BA " + gaData.trend.toFixed(1) + "%"; trendColor = "var(--bctext-1)"; }
+ }
+ stats.innerHTML =
+ stat("Overall grade", gaData.current == null ? "-" : gaData.current.toFixed(1) + "%") +
+ stat("Grade trend (last 5)", trendVal, trendColor) +
+ stat("Graded assignments", gaData.graded) +
+ stat("Ungraded / no score", gaData.ungraded);
+
+ const charts = panel.querySelector("#canvasrefined-ga-charts");
+ charts.style.display = "flex";
+ const fitYBox = panel.querySelector("#canvasrefined-ga-fity");
+ if (fitYBox) fitYBox.checked = gaFitY;
+
+ gaDrawPie(panel.querySelector("#canvasrefined-ga-pie"), panel.querySelector("#canvasrefined-ga-pie-tip"));
+ gaDrawLine(panel.querySelector("#canvasrefined-ga-line"), panel.querySelector("#canvasrefined-ga-line-tip"));
+}
+
+// --- Canvas-drawn charts --------------------------------------------------
+
+function gaSetupCanvas(canvas) {
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.parentNode.clientWidth, h = canvas.parentNode.clientHeight;
+ // Zero size means the panel isn't visible/attached yet, so skip drawing;
+ // the DOM observer / resize handler redraws once it has real dimensions.
+ if (w <= 0 || h <= 0) return null;
+ canvas.width = Math.max(1, Math.round(w * dpr));
+ canvas.height = Math.max(1, Math.round(h * dpr));
+ canvas.style.width = w + "px";
+ canvas.style.height = h + "px";
+ const ctx = canvas.getContext("2d");
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, w, h);
+ return { ctx, w, h };
+}
+
+function gaShowTooltip(el, x, y, html) {
+ el.innerHTML = html;
+ el.style.display = "block";
+ const parent = el.parentNode;
+ const tw = el.offsetWidth, th = el.offsetHeight;
+ // Place the tooltip beside the cursor/point so it never covers the chart
+ // underneath; flip to the left side when the right edge is tight.
+ let left = x + 14;
+ if (left + tw > parent.clientWidth) left = Math.max(0, x - tw - 14);
+ let top = y - th / 2;
+ top = Math.min(Math.max(0, top), Math.max(0, parent.clientHeight - th));
+ el.style.left = left + "px";
+ el.style.top = top + "px";
+}
+
+function gaDrawPie(canvas, tooltip) {
+ const size = gaSetupCanvas(canvas);
+ if (!size || !gaData) return;
+ const { ctx, w, h } = size;
+ const cx = w / 2, cy = h / 2;
+ const r = Math.max(10, Math.min(w, h) / 2 - 10);
+ const slices = GA_BUCKETS.map((b, i) => ({ label: b.label, value: gaData.counts[i], color: b.color }))
+ .concat([{ label: "Ungraded", value: gaData.ungraded, color: GA_UNGRADED_COLOR }]);
+ const total = slices.reduce((s, x) => s + x.value, 0);
+ const arcs = [];
+ if (total === 0) {
+ ctx.strokeStyle = "#888";
+ ctx.lineWidth = r - r * 0.55;
+ ctx.globalAlpha = 0.3;
+ ctx.beginPath(); ctx.arc(cx, cy, (r + r * 0.55) / 2, 0, Math.PI * 2); ctx.stroke();
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = "#888"; ctx.textAlign = "center";
+ ctx.font = "13px Lato, sans-serif";
+ ctx.fillText("No graded assignments yet", cx, cy);
+ }
+ let start = -Math.PI / 2;
+ for (const s of slices) {
+ if (!s.value) continue;
+ const ang = (s.value / total) * Math.PI * 2;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, start, start + ang);
+ ctx.arc(cx, cy, r * 0.55, start + ang, start, true);
+ ctx.closePath();
+ ctx.fillStyle = s.color;
+ ctx.fill();
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ arcs.push({ ...s, start, end: start + ang });
+ start += ang;
+ }
+ canvas._gaArcs = arcs;
+ canvas._gaCenter = { cx, cy, r };
+ if (canvas._gaHover) { canvas.removeEventListener("mousemove", canvas._gaHover); canvas.removeEventListener("mouseleave", canvas._gaLeave); }
+ canvas._gaHover = (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left - canvas._gaCenter.cx;
+ const y = e.clientY - rect.top - canvas._gaCenter.cy;
+ const dist = Math.hypot(x, y);
+ const hit = canvas._gaArcs.find(a => {
+ let d = Math.atan2(y, x);
+ if (d < a.start) d += Math.PI * 2;
+ return dist <= canvas._gaCenter.r && dist >= canvas._gaCenter.r * 0.55 && d >= a.start && d <= a.end;
+ });
+ if (hit) gaShowTooltip(tooltip, e.clientX - rect.left, e.clientY - rect.top, `${hit.label} : ${hit.value} assignment${hit.value === 1 ? "" : "s"}`);
+ else tooltip.style.display = "none";
+ };
+ canvas._gaLeave = () => { tooltip.style.display = "none"; };
+ canvas.addEventListener("mousemove", canvas._gaHover);
+ canvas.addEventListener("mouseleave", canvas._gaLeave);
+}
+
+function gaDrawLine(canvas, tooltip) {
+ const size = gaSetupCanvas(canvas);
+ if (!size || !gaData) return;
+ const pts = gaData.points;
+ const { ctx, w, h } = size;
+ const pad = { l: 38, r: 12, t: 12, b: 26 };
+ const text = getComputedStyle(document.body).color || "#666";
+ ctx.font = "11px Lato, sans-serif";
+ // Y axis: fixed 0-100 by default, or scaled to fit the data when the user
+ // toggled "Fit Y axis" (persisted in chrome.storage.local).
+ let yMin = 0, yMax = 100;
+ if (gaFitY) {
+ const vals = pts.map(p => p.grade).filter(v => v != null);
+ if (vals.length) {
+ const lo = Math.min(...vals), hi = Math.max(...vals);
+ if (hi - lo < 1e-9) {
+ yMin = Math.max(0, lo - 5);
+ yMax = hi + 5;
+ } else {
+ const margin = (hi - lo) * 0.1;
+ yMin = Math.max(0, lo - margin);
+ yMax = hi + margin;
+ }
+ if (yMax - yMin < 1) yMax = yMin + 1;
+ }
+ }
+ // Y grid: 5 evenly spaced lines across the current range.
+ ctx.textAlign = "right"; ctx.textBaseline = "middle";
+ const decimals = (yMax - yMin) <= 10 ? 1 : 0;
+ for (let i = 0; i <= 5; i++) {
+ const v = yMin + (yMax - yMin) * (i / 5);
+ const y = pad.t + (1 - (v - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
+ ctx.strokeStyle = "rgba(128,128,128,0.25)";
+ ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
+ ctx.fillStyle = text;
+ ctx.fillText(v.toFixed(decimals) + "%", pad.l - 6, y);
+ }
+ if (pts.length === 0) {
+ ctx.fillStyle = text; ctx.textAlign = "center";
+ ctx.fillText("No graded assignments yet", w / 2, h / 2);
+ return;
+ }
+ const X = (i) => pad.l + (pts.length === 1 ? (w - pad.l - pad.r) / 2 : (i / (pts.length - 1)) * (w - pad.l - pad.r));
+ const Y = (v) => pad.t + (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
+ // Full chart draw, parameterized by the hovered point index so mousemove
+ // can cheaply redraw with a highlight on the active dot.
+ const draw = (hover) => {
+ ctx.clearRect(0, 0, w, h);
+ // Y grid: 5 evenly spaced lines across the current range.
+ ctx.font = "11px Lato, sans-serif";
+ ctx.textAlign = "right"; ctx.textBaseline = "middle";
+ for (let i = 0; i <= 5; i++) {
+ const v = yMin + (yMax - yMin) * (i / 5);
+ const y = pad.t + (1 - (v - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
+ ctx.strokeStyle = "rgba(128,128,128,0.25)";
+ ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
+ ctx.fillStyle = text;
+ ctx.fillText(v.toFixed(decimals) + "%", pad.l - 6, y);
+ }
+ // Line + area fill.
+ ctx.beginPath();
+ pts.forEach((p, i) => { const x = X(i), y = Y(p.grade); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
+ ctx.strokeStyle = "#2563eb"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
+ ctx.lineTo(X(pts.length - 1), h - pad.b); ctx.lineTo(X(0), h - pad.b); ctx.closePath();
+ ctx.fillStyle = "rgba(37,99,235,0.12)"; ctx.fill();
+ // Points.
+ pts.forEach((p, i) => {
+ ctx.beginPath(); ctx.arc(X(i), Y(p.grade), 3, 0, Math.PI * 2);
+ ctx.fillStyle = "#2563eb"; ctx.fill();
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.stroke();
+ });
+ // X labels: first, middle, last due dates.
+ ctx.textAlign = "center"; ctx.textBaseline = "top";
+ [[0], [Math.floor((pts.length - 1) / 2)], [pts.length - 1]].forEach(([i]) => {
+ if (pts.length > 1 && pts.length > 2 && i !== 0 && i !== pts.length - 1 && Math.abs(X(i) - X(0)) < 40) return;
+ const label = pts[i].due || "";
+ if (!label) return;
+ ctx.fillStyle = text;
+ ctx.fillText(label, X(i), h - pad.b + 6);
+ });
+ // Hover indicator: dashed vertical guide plus a halo ring around the
+ // hovered dot so it's obvious which point the tooltip describes.
+ if (hover != null && pts[hover]) {
+ const hx = X(hover), hy = Y(pts[hover].grade);
+ ctx.save();
+ ctx.strokeStyle = "rgba(37,99,235,0.55)";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 3]);
+ ctx.beginPath(); ctx.moveTo(hx, pad.t); ctx.lineTo(hx, h - pad.b); ctx.stroke();
+ ctx.restore();
+ ctx.beginPath(); ctx.arc(hx, hy, 7, 0, Math.PI * 2);
+ ctx.fillStyle = "rgba(37,99,235,0.25)"; ctx.fill();
+ ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, Math.PI * 2);
+ ctx.fillStyle = "#2563eb"; ctx.fill();
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.stroke();
+ }
+ };
+ draw(null);
+ canvas._gaHoverIdx = null; // stale hover index from a previous draw
+ canvas._gaPts = pts;
+ canvas._gaX = X; canvas._gaY = Y; canvas._gaPad = pad;
+ if (canvas._gaHover) { canvas.removeEventListener("mousemove", canvas._gaHover); canvas.removeEventListener("mouseleave", canvas._gaLeave); }
+ canvas._gaHover = (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ let best = 0, bestD = Infinity;
+ pts.forEach((_, i) => { const d = Math.abs(X(i) - mx); if (d < bestD) { bestD = d; best = i; } });
+ if (best !== canvas._gaHoverIdx) {
+ canvas._gaHoverIdx = best;
+ draw(best);
+ }
+ const p = pts[best];
+ gaShowTooltip(tooltip, X(best), Y(p.grade),
+ `${p.title} Overall: ${p.grade == null ? "-" : p.grade.toFixed(1) + "%"} This: ${p.score}/${p.points} (${p.pct.toFixed(1)}%)${p.due ? ` Due: ${p.due}` : ""}`);
+ };
+ canvas._gaLeave = () => {
+ tooltip.style.display = "none";
+ if (canvas._gaHoverIdx != null) {
+ canvas._gaHoverIdx = null;
+ draw(null);
+ }
+ };
+ canvas.addEventListener("mousemove", canvas._gaHover);
+ canvas.addEventListener("mouseleave", canvas._gaLeave);
+}
+
+// Redraw open charts when the window is resized.
+window.addEventListener("resize", () => {
+ const panel = document.getElementById("canvasrefined-grade-analytics");
+ if (panel && gaOpen && gaData) renderGradeAnalytics();
+});
+
function getApiData() {
if (current_page === "/" || current_page === "" || options.better_todo || options.better_sidebar) {
getAssignments();
diff --git a/js/popup.js b/js/popup.js
index 9a46677..2e14cf2 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -1,4 +1,4 @@
-const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode', 'global_search'];
+const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics'];
const syncedSubOptions = [
"todo_hide_feedback",
"todo_full_height",
@@ -150,6 +150,7 @@ const defaultOptions = {
"relative_dues": false,
"equal_height_cards": false,
"hide_new_canvas": true,
+ "hide_sequence_footer": false,
"quiz_safe_mode": false,
"dark_mode_fix": [],
"assignment_states": {},