From 462186b1b7a4f6d792509c0d519d85778f5446de Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Sat, 29 Aug 2026 11:50:30 -0700 Subject: [PATCH] working on cool feature 2 --- _locales/en/messages.json | 629 ++++++++++++++++++------------------ css/content.css | 1 + css/popup.css | 1 + html/popup.html | 40 ++- js/background.js | 2 + js/content.js | 653 ++++++++++++++++++++++++++++++++++++++ js/popup.js | 3 +- 7 files changed, 1008 insertions(+), 321 deletions(-) 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 -
+
@@ -203,6 +203,14 @@ Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience.
+
+ + +
+
+
+
Dashboard Notes +
Global Canvas Search
+
+ + +
+
+
+
Grade Analytics + + + Adds an Analytics toggle to the left side of course grade pages with a score-distribution chart and your overall grade over time. + +
@@ -326,7 +346,7 @@
-
+
@@ -336,14 +356,6 @@
Center Cards
-
- - -
-
-
-
Dashboard Notes -
@@ -394,6 +406,14 @@
Hide New Dashboard
+
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 = ` +
+

Grade Analytics

+ +
+
+

Loading grade data…

+ + +
+ `; + 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) => + `
${val}
${cap}
`; + // 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": {},