credits+bump+grid fix+@grades+grade history fix

This commit is contained in:
Guy Sandler 2026-10-03 13:37:12 -07:00
parent b8bcf821e0
commit f30e197339
5 changed files with 159 additions and 3 deletions

View File

@ -217,6 +217,9 @@ If nobody is working on the feature, you can clone the dev branch and make your
- [Guy](https://github.com/guysandler) - [Guy](https://github.com/guysandler)
#### Fork Contributers
- [TabulateJarl8](https://github.com/TabulateJarl8)
#### Original Owner #### Original Owner
- [ksucpea](https://github.com/ksucpea) - [ksucpea](https://github.com/ksucpea)

View File

@ -370,6 +370,7 @@
.canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; } .canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; }
.canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; } .canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; }
.canvasrefined-gs-type-course { background: #dff3f3; color: #0b6e6e; border-color: #b3dede; } .canvasrefined-gs-type-course { background: #dff3f3; color: #0b6e6e; border-color: #b3dede; }
.canvasrefined-gs-type-grades { background: #e8eefc; color: #1d4ed8; border-color: #c3d2f5; }
.canvasrefined-gs-footer { .canvasrefined-gs-footer {
display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px; display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px;
color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8); color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8);

View File

@ -16,6 +16,9 @@ function setupNavigationListener() {
watchSubmissionPageButton(); watchSubmissionPageButton();
watchProfileLogoutPageButton(); watchProfileLogoutPageButton();
watchGradeAnalytics(); watchGradeAnalytics();
// Show/hide the Better Todo sidebar for the new page (course home vs
// sub-page) without needing a full reload.
updateTodoSidebarVisibility();
}; };
for (const method of ["pushState", "replaceState"]) { for (const method of ["pushState", "replaceState"]) {
const orig = history[method]; const orig = history[method];
@ -65,6 +68,22 @@ function isProfilePage() {
return /^\/profile(?:\/|$)/.test(current_page); return /^\/profile(?:\/|$)/.test(current_page);
} }
// The Better Todo sidebar belongs only on the main dashboard and each
// course's homepage (/courses/<id>, optional trailing slash) — not on
// sub-pages like assignments, modules, quizzes or the syllabus.
function isTodoAllowedPage() {
if (current_page === "/" || current_page === "") return true;
return /^\/courses\/\d+\/?$/.test(current_page);
}
// Show/hide an already-mounted Better Todo sidebar when the user navigates
// client-side into or out of an allowed page (Canvas' New UI doesn't reload).
function updateTodoSidebarVisibility() {
const list = document.getElementById("canvasrefined-todo-list");
if (!list) return;
list.style.display = isTodoAllowedPage() ? "" : "none";
}
// Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and // Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and
// /courses/123/quizzes/456/take (the actual quiz). // /courses/123/quizzes/456/take (the actual quiz).
function isQuizPage() { function isQuizPage() {
@ -4659,6 +4678,9 @@ function createTodoViewMore(location, type) {
function setupBetterTodo() { function setupBetterTodo() {
// Better Todo list is removed from quizzes (it interferes with the quiz page). // Better Todo list is removed from quizzes (it interferes with the quiz page).
if (isQuizPage()) return; if (isQuizPage()) return;
// Only the main dashboard and course homepages get the todo sidebar;
// sub-pages (assignments, modules, files, ...) keep Canvas's own sidebar.
if (!isTodoAllowedPage()) return;
if (options.better_todo !== true || isGradesPage()) return; if (options.better_todo !== true || isGradesPage()) return;
if (document.querySelector('#canvasrefined-todo-list')) return; if (document.querySelector('#canvasrefined-todo-list')) return;
// The dashboard MutationObserver can fire before getApiData() has assigned // The dashboard MutationObserver can fire before getApiData() has assigned
@ -7094,6 +7116,14 @@ function applyAestheticChanges() {
const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3);
const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12); const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12);
const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12); const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12);
// Canvas gives dashboard cards their own natural top/left margins
// for the default flow layout. The grid positions everything with
// its own row/column gaps, so strip those natural margins top and
// left — otherwise every card sits visibly lower and further right
// than the grid math says it should. (Card Spacing's right/bottom
// margins are untouched.)
style.textContent += `.ic-DashboardCard__box__container{margin-top:0!important;margin-left:0!important}`;
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-top:0!important;margin-left:0!important}`;
const centerGrid = options.center_cards === true; const centerGrid = options.center_cards === true;
const gpaOn = options.gpa_calc === true; const gpaOn = options.gpa_calc === true;
const gpaTop = options.gpa_calc_prepend === true; const gpaTop = options.gpa_calc_prepend === true;
@ -7623,7 +7653,7 @@ function openGlobalSearchModal() {
// Kick off indexing immediately so the first keystroke is fast. // Kick off indexing immediately so the first keystroke is fast.
ensureGlobalSearchIndex(); ensureGlobalSearchIndex();
// Render an initial hint. // Render an initial hint.
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course.</div>`; resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.</div>`;
} }
function closeGlobalSearchModal() { function closeGlobalSearchModal() {
@ -7961,7 +7991,7 @@ async function runGlobalSearch(query, resultsEl) {
} }
const index = await ensureGlobalSearchIndex(); const index = await ensureGlobalSearchIndex();
if (!query) { if (!query) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course.</div>`; resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.</div>`;
return []; return [];
} }
if (!index || !index.length) { if (!index || !index.length) {
@ -7969,6 +7999,32 @@ async function runGlobalSearch(query, resultsEl) {
return []; return [];
} }
// "@grades [course filter]" lists course grade pages instead of content.
// A bare "@grades" lists every course; "@grades calc" narrows to courses
// matching "calc" (same matching as the @course filter). Checked before
// the generic @course parse so "@grades" never gets eaten as a course
// name (e.g. a course actually called "Grades").
const gradesMatch = query.match(/(?:^|\s)@grades(?:\s+(.+))?$/i);
if (gradesMatch) {
const courseEntries = index.filter(i => i.type === "Course");
const filter = (gradesMatch[1] || "").trim();
const matching = courseEntries.filter(c => globalSearchCourseMatches(c, filter));
if (!matching.length) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">${filter ? `No course matches \u201c${escapeGlobalSearchHtml(filter)}\u201d.` : "No courses found to open grades for."}</div>`;
return [];
}
const gradeRows = matching.map(c => ({
type: "Grades",
title: c.title,
course: c.course,
courseId: c.courseId,
url: `${domain}/courses/${c.courseId}/grades`,
}));
resultsEl.innerHTML = gradeRows.map((item, i) => globalSearchRowHtml(item, i)).join("");
bindGlobalSearchRows(resultsEl, gradeRows);
return gradeRows;
}
// "@course name" narrows the search to one course. With no other words // "@course name" narrows the search to one course. With no other words
// after the filter (or a bare "@"), list the matching courses' homepages. // after the filter (or a bare "@"), list the matching courses' homepages.
const courseEntries = index.filter(i => i.type === "Course"); const courseEntries = index.filter(i => i.type === "Course");
@ -8958,6 +9014,29 @@ async function loadGradeAnalytics() {
// stashes the original posted score in a hidden "original_score" span (the // stashes the original posted score in a hidden "original_score" span (the
// "original_points" span holds points EARNED, not possible), while points // "original_points" span holds points EARNED, not possible), while points
// possible is only in the "/ 15" span displayed after the grade. // possible is only in the "/ 15" span displayed after the grade.
// Month abbreviations for parsing the grades table's due-date strings.
const GA_MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
// Parse a due-date string from the grades table into sortable parts.
// Handles "Sep 12", "Sep 12 at 11:59pm", "Sep 12 by 23:59" and
// "Sep 12, 2024 at 11:59pm". Returns { mon, day, minutes } or null.
function gaParseDueDate(due) {
const m = String(due || "").toLowerCase().match(/([a-z]{3})[a-z]*\.?\s+(\d{1,2})(?:[^\d]+\d{4})?(?:[^\d]{0,8}?(\d{1,2}):(\d{2})\s*(am|pm)?)?/);
if (!m) return null;
const mon = GA_MONTH_NAMES.indexOf(m[1]);
if (mon < 0) return null;
const day = parseInt(m[2], 10);
if (!isFinite(day) || day < 1 || day > 31) return null;
let minutes = 0;
if (m[3] !== undefined) {
let h = parseInt(m[3], 10);
minutes = h * 60 + parseInt(m[4], 10);
if (m[5] === "pm" && h < 12) minutes += 720;
if (m[5] === "am" && h === 12) minutes = parseInt(m[4], 10);
}
return { mon, day, minutes };
}
function gaParseNum(t) { function gaParseNum(t) {
if (!t) return null; if (!t) return null;
let s = String(t).replace(/\s+/g, ""); let s = String(t).replace(/\s+/g, "");
@ -8982,6 +9061,9 @@ function gaParseAssignmentRow(tr) {
status: (q(".submission_status")?.textContent || "").trim(), status: (q(".submission_status")?.textContent || "").trim(),
gid: (q(".assignment_group_id")?.textContent || "").trim(), gid: (q(".assignment_group_id")?.textContent || "").trim(),
due: (q("td.due")?.textContent || "").replace(/\s+/g, " ").trim(), due: (q("td.due")?.textContent || "").replace(/\s+/g, " ").trim(),
// Full hover tooltip date when present — a better parse source than
// the abbreviated cell text.
dueTitle: q("td.due [title]")?.getAttribute("title") || q("td.due")?.getAttribute("title") || "",
}; };
} }
@ -9021,6 +9103,67 @@ function computeGradeAnalyticsFromPage(table) {
counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++; counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++;
} }
// Sort graded work chronologically by due date before building the
// running-grade timeline. The chart and trend assume row order is
// chronological, but the grades table isn't guaranteed to be (it follows
// the course's assignment ordering, and users can re-sort it), which left
// the grade-history line jumping back and forth in time.
//
// Due strings have no year, so months are cyclic: a school-year course
// (Sep → May) and a calendar-year course (Jan → Dec) both count upward,
// just from different "year starts". Each candidate rotation r (month
// the year window starts at) maps a due month to (mon - r) mod 12; the
// rotation whose keys produce the least total backward drift in the
// table's row order wins, with ties going to the first dated row's
// month (which reproduces the chart's walking-month behavior on
// already-ordered data). This stays correct whether rows come in
// due-date order, a different sort order, or shuffled. Day-of-month and
// time break ties within a month; undated rows inherit their nearest
// dated neighbour's key (forward, then backward fill); equal keys keep
// their original relative order (stable sort).
const parsed = graded.map(a => gaParseDueDate(a.dueTitle || a.due));
// Cost of a rotation = total backward drift it forces on the row order
// (sum of positive key drops between consecutive dated rows). Magnitude
// beats counting steps: a shuffle costs real months, a year-wrap costs
// ~11/12 of a cycle only if it's the wrong rotation.
const rotationCost = (r) => {
let cost = 0, prev = null;
for (const d of parsed) {
if (!d) continue;
const k = (d.mon - r + 12) % 12;
if (prev != null && k < prev) cost += prev - k;
prev = k;
}
return cost;
};
let bestRotation = 0, bestCost = Infinity;
for (let r = 0; r < 12; r++) {
const cost = rotationCost(r);
if (cost < bestCost) { bestCost = cost; bestRotation = r; }
}
const firstDated = parsed.find(d => d);
if (firstDated) {
const pref = firstDated.mon;
// Re-scan for the preferred rotation on a cost tie.
for (let r = 0; r < 12; r++) {
if (rotationCost(r) === bestCost && r === pref) { bestRotation = r; break; }
}
}
const keys = parsed.map(d => d ? [(d.mon - bestRotation + 12) % 12, d.day, d.minutes] : null);
let lastKey = null;
for (let i = 0; i < keys.length; i++) { if (keys[i] == null) keys[i] = lastKey; else lastKey = keys[i]; }
let nextKey = null;
for (let i = keys.length - 1; i >= 0; i--) { if (keys[i] == null) keys[i] = nextKey; else nextKey = keys[i]; }
const order = graded.map((_, i) => i).sort((x, y) => {
const kx = keys[x], ky = keys[y];
if (!kx || !ky) return x - y;
for (let j = 0; j < 3; j++) if (kx[j] !== ky[j]) return kx[j] - ky[j];
return x - y;
});
const sortedGraded = order.map(i => graded[i]);
graded.length = 0;
graded.push(...sortedGraded);
// Running overall grade, in the page's row order, using Canvas's own // Running overall grade, in the page's row order, using Canvas's own
// weighting algorithm (GradeCalculator): sum each group's pct × weight // weighting algorithm (GradeCalculator): sum each group's pct × weight
// over groups that have graded work, then scale up to 100% only when // over groups that have graded work, then scale up to 100% only when

View File

@ -1329,6 +1329,15 @@ function setup() {
if (option === "dark_mode") { if (option === "dark_mode") {
toggleAlternateColorsVisibility(status); toggleAlternateColorsVisibility(status);
} }
if (option === "hide_personal_details" && status === false) {
// "Temporarily remove all course images" is a sub-option of
// the privacy mode: switching the mode off restores the
// images too (the content script listens for this key and
// removes its hiding stylesheet).
const imgCheckbox = document.getElementById("hide_course_images");
if (imgCheckbox) imgCheckbox.checked = false;
chrome.storage.sync.set({ "hide_course_images": false });
}
toggleSubOptionsVisibility(option, status); toggleSubOptionsVisibility(option, status);
}); });
}); });

View File

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Canvas Refined", "name": "Canvas Refined",
"description": "Even More Feature packed extension for Canvas.", "description": "Even More Feature packed extension for Canvas.",
"version": "7.0.5", "version": "7.1.0",
"icons": { "icons": {
"16": "icon/icon-16.png", "16": "icon/icon-16.png",
"32": "icon/icon-32.png", "32": "icon/icon-32.png",