const domain = window.location.origin;
let current_page = window.location.pathname;
// Canvas' "New Canvas" UI navigates client-side via history.pushState/
// replaceState without a full page reload. current_page is captured once at
// document_start, so without this hook it goes stale and page-specific features
// (Back to Assignment button, sequence-footer removal, profile logout button)
// never activate when the user clicks into a page instead of loading it directly.
function setupNavigationListener() {
const update = () => {
const next = window.location.pathname;
if (next === current_page) return;
current_page = next;
// Re-run the page-scoped watchers so they evaluate against the new URL.
watchSequenceFooter();
watchSubmissionPageButton();
watchProfileLogoutPageButton();
watchGradeAnalytics();
};
for (const method of ["pushState", "replaceState"]) {
const orig = history[method];
history[method] = function (...args) {
const ret = orig.apply(this, args);
update();
return ret;
};
}
window.addEventListener("popstate", update);
}
function getCurrentCourseId() {
const match = current_page.match(/^\/courses\/(\d+)(?:\/|$)/);
return match ? parseInt(match[1]) : null;
}
function getSidebarLayoutMode() {
if (current_page.match(/^\/courses\/(\d+)(?:\/|$)/)) return "course";
if (isProfilePage()) return "course";
if (current_page === "/courses" || current_page === "/courses/") return "dash";
if (current_page === "/" || current_page === "") return "dash";
return "dash";
}
function isGradesPage() {
return /^\/courses\/\d+\/grades(?:\/|$)/.test(current_page);
}
function isCoursesIndexPage() {
return /^\/courses\/?$/.test(current_page);
}
function isGroupsIndexPage() {
return /^\/groups\/?$/.test(current_page);
}
function isConversationsPage() {
return /^\/conversations(?:\/|$)/.test(current_page);
}
function isAccountsPage() {
return /^\/accounts(?:\/|$)/.test(current_page);
}
function isProfilePage() {
return /^\/profile(?:\/|$)/.test(current_page);
}
// Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and
// /courses/123/quizzes/456/take (the actual quiz).
function isQuizPage() {
return /^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(current_page);
}
function isQuizTakePage() {
return /^\/courses\/\d+\/quizzes\/\d+\/take(?:\/|$)/.test(current_page);
}
function isQuizPreTakePage() {
return isQuizPage() && !isQuizTakePage();
}
// "Quiz safe mode" disables features that interfere with the default Canvas
// quiz experience. Only active on quiz pages when the user has opted in.
function quizSafeModeActive() {
return isQuizPage() && options.quiz_safe_mode === true;
}
// Read the URL live: Canvas' "New Canvas" UI navigates client-side via
// history.pushState/replaceState, and current_page (captured at document_start)
// can be stale when the user clicks into a submission page. Reading
// window.location.pathname at check time makes this correct regardless.
function getSubmissionAssignmentLink() {
const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/);
if (!match) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/`;
}
// The content container isn't always #content on every Canvas layout (submission
// pages in particular may render into .ic-Layout-contentMain or #main). Match the
// quiz-safe-mode banner's container finder so the button lands in the visible
// content area; fall back to
so injection never silently no-ops.
function findContentContainer() {
return document.querySelector(".ic-Layout-contentMain")
|| document.getElementById("content")
|| document.querySelector("#main")
|| document.body;
}
let submissionPageButtonObserver = null;
let submissionButtonScheduled = false;
let assignmentButtonScheduled = false;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
let sequenceFooterObserver = null;
// Current user id, needed to build "Go to Grades" links on assignment pages.
// The page's ENV global isn't visible to content scripts (isolated world), so
// ask the Canvas API once and cache the result.
// undefined = not fetched yet, null = fetch failed, number = ok.
let currentUserIdCache;
let currentUserIdPromise = null;
function ensureCurrentUserId() {
if (currentUserIdCache !== undefined) return Promise.resolve(currentUserIdCache);
if (!currentUserIdPromise) {
currentUserIdPromise = getData(`${domain}/api/v1/users/self`)
.then(user => {
currentUserIdCache = (user && user.id) || null;
})
.catch(() => {
currentUserIdCache = null;
})
.then(() => {
currentUserIdPromise = null;
return currentUserIdCache;
});
}
return currentUserIdPromise;
}
// Assignment pages (/courses/123/assignments/456) link to the current user's
// submission ("grades") page for that assignment. The lookahead keeps this
// from matching the submission pages themselves (/.../submissions/678).
function getAssignmentGradesLink() {
const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)(?!\/submissions)(?:\/|$)/);
if (!match || currentUserIdCache == null) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/submissions/${currentUserIdCache}`;
}
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return;
// Place the button inline with the "Submission Details" heading and the
// grade-values table, inside the .submission-details-header__heading-and-grades
// flex row (appended so it sits to the right of the grade summary). Only inject
// once that row exists; if it's not there yet the persistent MutationObserver
// re-tries on the next DOM change so we never fall back to body/#content
// (which would put the button at the bottom of the page).
const row = document.querySelector(".submission-details-header__heading-and-grades")
|| document.querySelector(".submission-details-header")
|| document.querySelector(".submission_details");
if (!row || row.querySelector("#canvasrefined-assignment-return")) return;
// Insert between the h1 heading and the grade-summary div so it reads
// [Heading] [Back to Assignment] [Grade]. Falls back to appending if the
// grade-summary div isn't found for some reason.
const gradeSummary = row.querySelector(".submission-details-header__grade-summary");
const btn = makeElement("a", row, {
id: "canvasrefined-assignment-return",
className: "canvasrefined-custom-btn",
href: assignmentLink,
textContent: "Back to Assignment",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:center;margin-left:auto;margin-right:12px;padding:6px 12px;text-decoration:none;font-weight:700;color:inherit!important;",
});
if (gradeSummary && gradeSummary.parentNode === row) {
row.insertBefore(btn, gradeSummary);
}
}
// Assignment pages: /courses/123/assignments/456 — add a "Go to Grades" button
// to the right edge of the title row.
function addAssignmentPageButton() {
const link = getAssignmentGradesLink();
if (!link) return;
// Place the button inside the assignment header's .title-content block,
// pinned to its right edge on the title's line. .title-content is a plain
// block wrapping the
, so switch it to a flex row (h1 left, button
// right); the h1 still wraps its text when long.
const titleContent = document.querySelector(".assignment-title .title-content")
|| document.querySelector(".title-content");
if (!titleContent || titleContent.querySelector("#canvasrefined-assignment-grades")) return;
titleContent.style.display = "flex";
titleContent.style.alignItems = "center";
titleContent.style.gap = "12px";
makeElement("a", titleContent, {
id: "canvasrefined-assignment-grades",
className: "canvasrefined-custom-btn",
href: link,
textContent: "Go to Grades",
style: "display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;padding:6px 12px;text-decoration:none;font-weight:700;font-size:16px;color:inherit!important;white-space:nowrap;",
});
}
function addProfileLogoutPageButton() {
if (!isProfilePage()) return;
const content = document.getElementById("content");
if (!content || content.querySelector("#canvasrefined-profile-logout")) return;
makeElement("a", content, {
id: "canvasrefined-profile-logout",
className: "canvasrefined-custom-btn",
href: `${domain}/logout`,
textContent: "Logout",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:flex-start;margin:0 0 12px 0;padding:10px 14px;text-decoration:none;font-weight:700;",
}, true);
}
function ensureProfileLogoutPageButton() {
if (!isProfilePage()) return false;
const content = document.getElementById("content");
if (!content) return false;
if (content.querySelector("#canvasrefined-profile-logout")) return true;
addProfileLogoutPageButton();
return Boolean(content.querySelector("#canvasrefined-profile-logout"));
}
function watchProfileLogoutPageButton() {
if (!isProfilePage()) {
document.getElementById("canvasrefined-profile-logout")?.remove();
return;
}
if (ensureProfileLogoutPageButton()) return;
if (profileLogoutButtonObserver) return;
profileLogoutButtonObserver = new MutationObserver(() => {
if (ensureProfileLogoutPageButton() && profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
});
profileLogoutButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
}, 10000);
}
// Reconcile the button against the current page on a rAF-throttled schedule.
// Canvas (React-based "New Canvas" UI) re-renders the content area on SPA
// navigation and can wipe our injected node; a persistent observer re-adds it.
function maintainSubmissionPageButton() {
if (submissionButtonScheduled) return;
submissionButtonScheduled = true;
requestAnimationFrame(() => {
submissionButtonScheduled = false;
const link = getSubmissionAssignmentLink();
const existing = document.getElementById("canvasrefined-assignment-return");
if (!link) {
existing?.remove();
return;
}
if (existing) {
if (existing.href !== link) existing.href = link;
return;
}
addSubmissionPageButton();
});
}
// Same reconciliation pattern as maintainSubmissionPageButton, but for the
// "Go to Grades" button on assignment pages. The grades link needs the
// current user id, so on the first assignment page visit we kick off the API
// fetch and re-run once it resolves.
function maintainAssignmentPageButton() {
if (assignmentButtonScheduled) return;
assignmentButtonScheduled = true;
requestAnimationFrame(() => {
assignmentButtonScheduled = false;
const isAssignmentPage = /^\/courses\/\d+\/assignments\/\d+(?!\/submissions)(?:\/|$)/.test(window.location.pathname);
const existing = document.getElementById("canvasrefined-assignment-grades");
if (!isAssignmentPage) {
if (existing) {
const titleContent = existing.closest(".title-content");
existing.remove();
// Undo the flex-row layout we applied to the title block.
if (titleContent) {
titleContent.style.display = "";
titleContent.style.alignItems = "";
titleContent.style.gap = "";
}
}
return;
}
if (currentUserIdCache === undefined) {
ensureCurrentUserId().then(() => maintainAssignmentPageButton());
return;
}
const link = getAssignmentGradesLink();
if (!link) return;
if (existing) {
if (existing.href !== link) existing.href = link;
return;
}
addAssignmentPageButton();
});
}
function isAssignmentPage() {
return /^\/courses\/\d+\/assignments(?:\/\d+)?(?:\/|$)/.test(current_page);
}
function removeSequenceFooter() {
if (options.hide_sequence_footer !== true) return false;
if (!isAssignmentPage()) return false;
const sequenceFooter = document.getElementById("sequence_footer");
if (!sequenceFooter) return false;
sequenceFooter.remove();
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();
sequenceFooterObserver = null;
}
});
sequenceFooterObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
}, 10000);
}
// One persistent, rAF-throttled observer that keeps both assignment-page
// navigation buttons present: the "Back to Assignment" button on submission
// pages and the "Go to Grades" button on assignment pages. Unlike the old
// 10s-disconnecting observer, this survives Canvas' post-navigation re-renders
// that remove injected nodes. The extra delayed checks cover React hydration
// that wipes the button after our first add without emitting any later mutation
// for the observer to catch.
function maintainAssignmentNavButtons() {
maintainSubmissionPageButton();
maintainAssignmentPageButton();
}
function watchSubmissionPageButton() {
if (submissionPageButtonObserver) return;
maintainAssignmentNavButtons();
submissionPageButtonObserver = new MutationObserver(maintainAssignmentNavButtons);
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
for (const ms of [300, 800, 1600, 3000, 5000]) {
setTimeout(maintainAssignmentNavButtons, ms);
}
}
function removeNewCanvasButton() {
document.querySelectorAll('[data-testid="switch-to-new-dashboard-button"]').forEach(btn => btn.remove());
}
function watchNewCanvasButton() {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
if (options.hide_new_canvas !== true) return;
removeNewCanvasButton();
let newCanvasButtonScheduled = false;
newCanvasButtonObserver = new MutationObserver((mutationList) => {
if (options.hide_new_canvas !== true) {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
return;
}
// Only scan when nodes were actually added, and coalesce to one pass per frame.
let added = false;
for (const mutation of mutationList) {
if (mutation.addedNodes && mutation.addedNodes.length) { added = true; break; }
}
if (!added || newCanvasButtonScheduled) return;
newCanvasButtonScheduled = true;
requestAnimationFrame(() => {
newCanvasButtonScheduled = false;
if (options.hide_new_canvas !== true) return;
removeNewCanvasButton();
});
});
newCanvasButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
}
async function getActiveCustomBackground() {
const syncOpts = await chrome.storage.sync.get([
"customBackgroundDaily",
"customBackgroundNasaDaily",
"customBackgroundLink",
"customBackgroundScale",
]);
if (syncOpts.customBackgroundNasaDaily === true) {
return await getNasaDailyBackground();
}
if (syncOpts.customBackgroundDaily === true) {
const dailyPreset = await getDailyBackgroundPreset();
if (dailyPreset) {
return {
url: dailyPreset.url,
scale: dailyPreset.scale,
};
}
}
if (syncOpts.customBackgroundLink && syncOpts.customBackgroundLink !== "") {
return {
url: syncOpts.customBackgroundLink,
scale: syncOpts.customBackgroundScale || 100,
};
}
return null;
}
async function getDailyBackgroundPreset() {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cacheKey = `picsum_daily_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (cached[cacheKey]) return cached[cacheKey];
const url = `https://picsum.photos/seed/${dateStr}/1920/1080`;
const result = { url, scale: 100 };
await chrome.storage.local.set({ [cacheKey]: result });
return result;
}
async function getNasaDailyBackground() {
try {
return await chrome.runtime.sendMessage({ type: "getNasaBackground" });
} catch (error) {
console.error("[CanvasRefined] Failed to fetch NASA APOD:", error);
return null;
}
}
let nasaInfoOverlayEl = null;
function isDashboardPage() {
return !!document.querySelector("#DashboardCard_Container");
}
function createNasaInfoOverlay() {
if (options.customBackgroundNasaDaily !== true) return;
if (nasaInfoOverlayEl || !isDashboardPage()) return;
const contentMain = document.querySelector("#content.ic-Layout-contentMain, .ic-Layout-contentMain");
if (!contentMain) return;
if (getComputedStyle(contentMain).position === "static") {
contentMain.style.position = "relative";
}
nasaInfoOverlayEl = document.createElement("div");
nasaInfoOverlayEl.id = "canvasrefined-nasa-info-overlay";
nasaInfoOverlayEl.style.cssText = "position:absolute;right:24px;bottom:24px;z-index:9999;";
nasaInfoOverlayEl.innerHTML = `
`;
const icon = nasaInfoOverlayEl.querySelector("#nasa-info-icon");
const panel = nasaInfoOverlayEl.querySelector("#nasa-info-panel");
const populatePanel = async () => {
// Search backward up to 7 days for the APOD actually cached, since today's may not be ready.
const date = new Date();
for (let i = 0; i < 7; i++) {
const dateStr = date.toISOString().slice(0, 10);
const cacheKey = `nasa_apod_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (!cached[cacheKey]) {
date.setDate(date.getDate() - 1);
continue;
}
const metadataKey = `nasa_apod_meta_${dateStr}`;
const metadata = await chrome.storage.local.get(metadataKey);
const meta = metadata[metadataKey];
if (!meta) return false;
document.getElementById("nasa-info-title").textContent = meta.title || "";
document.getElementById("nasa-info-date").textContent = `Date: ${meta.date}`;
document.getElementById("nasa-info-credit").textContent = meta.copyright ? `Credit: ${meta.copyright}` : "";
document.getElementById("nasa-info-explanation").textContent = meta.explanation || "No description available.";
return true;
}
return false;
};
let pinned = false;
const showPanel = async () => {
if (pinned) return;
if (await populatePanel()) panel.style.display = "block";
};
const hidePanel = () => {
if (pinned) return;
panel.style.display = "none";
};
const togglePanel = async () => {
if (pinned) {
pinned = false;
panel.style.display = "none";
} else {
pinned = true;
if (await populatePanel()) panel.style.display = "block";
}
};
icon.addEventListener("mouseenter", showPanel);
icon.addEventListener("mouseleave", hidePanel);
panel.addEventListener("mouseenter", showPanel);
panel.addEventListener("mouseleave", hidePanel);
icon.addEventListener("click", togglePanel);
contentMain.appendChild(nasaInfoOverlayEl);
}
function removeNasaInfoOverlay() {
if (nasaInfoOverlayEl) {
nasaInfoOverlayEl.remove();
nasaInfoOverlayEl = null;
}
}
function getSidebarStateMode(mode = getSidebarLayoutMode()) {
return mode === "course" ? "course" : "dashboard";
}
function getSidebarStateKey(mode = getSidebarLayoutMode()) {
return `better_sidebar_expanded_${getSidebarStateMode(mode)}`;
}
async function getSidebarExpandedState(mode = getSidebarLayoutMode()) {
const key = getSidebarStateKey(mode);
const result = await chrome.storage.local.get(key);
return result[key] ?? false;
}
function setSidebarExpandedState(mode, expanded) {
chrome.storage.local.set({ [getSidebarStateKey(mode)]: expanded });
}
let assignments = null;
let grades = null;
let announcements = [];
let completed = [];
let assignmentsDue = [];
// Render-generation counters. createTodoSections/loadCardAssignments render
// asynchronously (inside a .then), while several code paths re-render by
// calling clearTodoList() + createTodoSections() again. Without a guard, a
// render whose data promise resolves AFTER a newer clear+render started would
// append a second full set of group wrappers/items on top of the fresh render
// — the reported "todo list is all doubled" glitch. Each render captures the
// counter at call time and aborts if a newer render has started by the time
// its data arrives.
let todoRenderGen = 0;
let cardRenderGen = 0;
let options = {};
let timeCheck = null;
let reminderCheck = null;
// Set while a background planner-cache refresh is in flight so overlapping
// schedules (multiple loads, multiple tabs) can't run concurrently.
let plannerRefreshRunning = false;
let betterSidebarLoading = false;
let dashboardReadyTimer = null;
let sidebarReadyTimer = null;
// Signature of the dashboard card set last time we ran the full setup pass.
// The MutationObserver in checkDashboardReady fires on every childList change
// in the document — including the ones our own setup pass (loadCardAssignments,
// customizeCards, etc.) causes. Without a guard, that re-triggers the observer
// and re-runs the whole pass every animation frame (an infinite reflow loop).
// We only need to re-run when Canvas actually changes the dashboard cards, so
// we skip the heavy pass whenever the card set is unchanged.
let lastDashboardCardSignature = null;
let sidebarBadgeObserver = null;
let sidebarBadgeSyncTimer = null;
let sidebarBadgeWatchRetries = 0;
/*
Start
*/
/*
Todo Reminders
*/
const canvas_svg = ``;
async function insertReminders(reminders) {
const storage = await chrome.storage.sync.get("reminders");
const stored = Array.isArray(storage["reminders"]) ? storage["reminders"] : [];
// Keyed by link ("h"). Dedupes while loading: older versions replaced a
// matching entry in place for submitted items (c === -1) but ALSO appended
// the insert again, so one extra copy accumulated in storage on every
// load/refresh. Rebuilding the map here heals already-duplicated storage.
const byHref = new Map();
for (const r of stored) {
if (!r || !r.h) continue;
const prev = byHref.get(r.h);
if (!prev || (prev.c !== -1 && r.c === -1)) byHref.set(r.h, r);
}
let changed = byHref.size !== stored.length;
for (const insert of reminders) {
if (!insert || !insert.h) continue;
const prev = byHref.get(insert.h);
if (!prev) {
byHref.set(insert.h, insert);
changed = true;
} else if (insert.c === -1 && prev.c !== -1) {
// Recently submitted: update the existing entry in place instead of
// adding a second copy. An unsubmitted insert never overwrites an
// existing entry, and a submitted entry stays submitted.
byHref.set(insert.h, insert);
changed = true;
}
}
if (changed) chrome.storage.sync.set({ "reminders": [...byHref.values()] });
}
async function hideReminder(href) {
const storage = await chrome.storage.sync.get("reminders");
for (let i = 0; i < storage["reminders"].length; i++) {
if (storage["reminders"][i]["h"] === href) {
storage["reminders"][i]["c"]++;
chrome.storage.sync.set({ "reminders": storage["reminders"] });
break;
}
}
}
function createReminder(reminder, location) {
const remaining = getRelativeDate(new Date(reminder.d));
const wrapper = makeElement("div", location, { "className": "canvasrefined-reminder-wrapper" });
const container = makeElement("div", wrapper, { "className": "canvasrefined-reminder-container" });
const svg = makeElement("div", container, { "innerHTML": canvas_svg });
const content = makeElement("a", container, { "className": "canvasrefined-reminder-content", "href": reminder.h, "target": "_blank" });
const title = makeElement("h2", content, { "className": "canvasrefined-reminder-title", "textContent": reminder.t });
const due = makeElement("p", content, { "className": "canvasrefined-reminder-due", "textContent": `Assignment due in ${remaining.time}` });
const hidebtn = makeElement("btn", wrapper, { "className": "canvasrefined-reminder-hide", "textContent": "x" });
hidebtn.addEventListener("click", () => {
hideReminder(reminder.h);
wrapper.remove();
});
return container;
}
async function reminderWatch() {
const sync = await chrome.storage.sync.get("remind");
if (sync["remind"] !== true) {
if (document.getElementById("canvasrefined-reminders")) document.getElementById("canvasrefined-reminders").style.display = "none";
return;
}
const container = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
container.style.display = "flex";
container.textContent = "";
const alertPeriod = 1000 * 60 * 60 * 6; // 6 hours
const alertPeriod2 = 1000 * 60 * 60 * 2; // 2 hours
const storage = await chrome.storage.sync.get(["reminders", "reminder_count"]);
const now = (new Date()).getTime();
storage["reminders"].forEach((reminder, index) => {
if (reminder.d < now) {
storage["reminders"].splice(index, 1);
} else if ((reminder.c == 0 && reminder.d < now + alertPeriod) || (reminder.c == 1 && reminder.d < now + alertPeriod2)) {
createReminder(reminder, container);
}
});
chrome.storage.sync.set({ "reminders": storage["reminders"] });
}
function updateReminders() {
if (!assignments || typeof assignments.then !== "function") return;
const fiveDays = 1000 * 60 * 60 * 24 * 5;
const now = (new Date()).getTime();
const list = [];
assignments.then(data => {
data.forEach(item => {
const due = (new Date(item.plannable_date)).getTime();
if (item.plannable_type === "announcement") return;
if (due < now) return;
if (due > now + fiveDays * 2) return;
// { due, title, href, hide count }
// hide count of -1 indicates the item has a submission
list.push({ "d": due, "t": item.plannable.title, "h": domain + item.html_url, "c": item?.submissions?.submitted || false ? -1 : 0 });
});
insertReminders(list);
});
}
function showExampleReminder() {
const location = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
if (options.remind !== true) {
location.remove();
return;
}
location.textContent = "";
const example = createReminder({ "d": new Date(), "t": "This is an example reminder", }, location);
example.querySelector(".canvasrefined-reminder-due").textContent = "This notification will pop up in other pages to remind you of incomplete assignments that are due in less than 6 hours." /*It will notify again at 2 hours if the 'Remind 2x' option is on."*/;
}
isDomainCanvasPage();
function isDomainCanvasPage() {
chrome.storage.sync.get(['custom_domain', 'dark_mode', 'dark_preset', 'device_dark', 'remind'], result => {
options = result;
if (result.custom_domain.length && result.custom_domain[0] !== "") {
for (let i = 0; i < result.custom_domain.length; i++) {
if (domain.includes(result.custom_domain[i])) {
startExtension();
return;
}
}
// if the code reaches this point, its not a canvas page so run the reminders
setTimeout(reminderWatch, 2000);
setInterval(reminderWatch, 60000);
// turn the reminders on/off if the option is changed
chrome.storage.onChanged.addListener((changes) => {
Object.keys(changes).forEach(key => {
if (key === "remind") reminderWatch();
})
})
} else {
setupCustomURL();
}
});
}
function startExtension() {
// Remove footer robustly - run first so a crash below can't block it
const removeFooter = () => {
const footer = document.querySelector('footer#footer.ic-app-footer, footer#footer');
if (footer) footer.remove();
};
removeFooter();
let footerScheduled = false;
const footerObserver = new MutationObserver(() => {
// Canvas mutates the DOM constantly; only check for the footer at most once
// per animation frame instead of running a querySelector on every mutation.
if (footerScheduled) return;
footerScheduled = true;
requestAnimationFrame(() => {
footerScheduled = false;
removeFooter();
});
});
footerObserver.observe(document.documentElement, { childList: true, subtree: true });
// Start the submission-page "Back to Assignment" button watcher and the SPA
// navigation hook immediately, before the async storage callbacks below.
// These don't depend on `options`, and running them first means a throw in
// any later init step can't prevent the button from appearing. The watcher
// reads window.location.pathname live and uses a persistent MutationObserver
// to (re)inject the button once the content container exists.
setupNavigationListener();
watchSubmissionPageButton();
toggleDarkMode();
// Include bg_opacity/bg_blur so setupBetterSidebar (called below) tints the
// course-content panel with the user's slider values on first load, instead
// of falling back to the defaults until a slider is touched.
chrome.storage.sync.get(["better_sidebar", "sidebar_scale", "bg_opacity", "bg_blur"], result => {
options = { ...options, ...result };
ensureBetterSidebar();
});
chrome.storage.sync.get(null, result => {
options = { ...options, ...result };
applyTodoAlternateColors();
toggleAutoDarkMode();
// toggleScheduledReminders();
getApiData();
checkDashboardReady();
loadCustomFont();
applyAestheticChanges();
watchNewCanvasButton();
changeFavicon();
updateReminders();
applyCustomBackground();
ensureBetterSidebar();
watchSequenceFooter();
watchProfileLogoutPageButton();
watchGradeAnalytics();
setupQuizSafeModeBanner();
setupGlobalSearch();
setTimeout(() => runDarkModeFixer(false), 800);
setTimeout(() => runDarkModeFixer(false), 4500);
});
// Custom CSS lives in storage.local (unlimitedStorage) because
// storage.sync's 8KB per-item quota silently dropped long stylesheets.
// Merge it into options once sync data is in, re-applying styles. If the
// user has CSS only in the legacy sync key (pre-migration), copy it over
// to local so it keeps working — sync keeps its copy as a rollback-safe
// backup until the next popup save.
chrome.storage.local.get("custom_styles", local => {
if (local && local["custom_styles"] !== undefined) {
options = { ...options, custom_styles: local["custom_styles"] };
applyAestheticChanges();
} else {
chrome.storage.sync.get("custom_styles", sync => {
if (sync && sync["custom_styles"]) {
chrome.storage.local.set({ custom_styles: sync["custom_styles"] });
options = { ...options, custom_styles: sync["custom_styles"] };
applyAestheticChanges();
}
});
}
});
chrome.runtime.onMessage.addListener(recieveMessage);
chrome.storage.onChanged.addListener(applyOptionsChanges);
console.log("Canvas Refined - running");
}
function applyOptionsChanges(changes) {
let rewrite = {};
Object.keys(changes).forEach(key => {
rewrite[key] = changes[key].newValue;
});
options = { ...options, ...rewrite };
// when an option is updated it will call the necessary functions again
// so any changes made in the menu no longer require a refresh to apply
Object.keys(changes).forEach(key => {
switch (key) {
case "dark_mode":
case "dark_preset":
case "device_dark":
toggleDarkMode();
applyTodoAlternateColors();
// "Ignore card colors" picks black vs. theme text color based on dark
// mode, so re-render the Better Todo list to keep it in sync.
if (options.todo_ignore_card_colors && options.better_todo && document.getElementById("better-todo-main")) {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "todo_alternate_colors":
applyTodoAlternateColors();
break;
case "auto_dark":
case "auto_dark_start":
case "auto_dark_end":
toggleAutoDarkMode();
break;
case "gradient_cards":
changeGradientCards();
break;
case "dashboard_notes":
case "dashboard_notes_text":
case "dashboard_notes_mode":
loadDashboardNotes();
break;
case "dashboard_grades":
case "grade_hover":
case "card_letter":
if (!grades) getGrades();
insertGrades();
break;
case "assignments_due":
case "num_assignments":
if (!assignments) getAssignments();
if (
document.querySelectorAll(".canvasrefined-card-assignment")
.length === 0
)
setupCardAssignments();
loadCardAssignments();
// The card overflow fix in applyAestheticChanges() depends on
// assignments_due, so re-run it when that option toggles to keep
// the overflow rule in sync without a page reload.
applyAestheticChanges();
break;
case "custom_assignments":
case "assignment_date_format":
case "card_overdues":
case "relative_dues":
cardAssignments = preloadAssignmentEls();
loadCardAssignments();
break;
case "equal_height_cards":
// Stretch or reset card heights in place instead of rebuilding rows.
equalizeCardHeights();
break;
case "custom_cards":
customizeCards();
// Hiding/unhiding a card changes which courses appear in the todo
// list and the progress display, so re-render them immediately.
if (options.better_todo && document.getElementById("better-todo-main")) {
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "custom_cards_2":
case "custom_cards_3":
customizeCards();
break;
case "todo_hr24":
case "todo_separate_scrollbar":
case "num_todo_items":
case "hover_preview":
case "todo_timeframe":
// case "todo_overdues":
case "todo_hide_feedback":
case "todo_hide_read":
case "todo_full_height":
case "todo_ignore_card_colors":
case "todo_remove_icons":
case "todo_show_scores":
case "custom_cards_3":
if (options.better_todo && document.getElementById("better-todo-main")) {
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
// A new timeframe starts back at the current window.
betterTodoTimeframeOffset = 0;
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "gpa_calc":
case "gpa_calc_prepend":
case "gpa_calc_weighted":
case "gpa_calc_cumulative":
if (!grades) getGrades();
setupGPACalc();
// The GPA elements are placed on dedicated grid rows when Card
// Grid is on (see applyAestheticChanges), so toggling GPA on/off
// has to regenerate the grid CSS too.
if (options.card_grid === true) debouncedApplyAestheticChanges();
break;
case "gpa_calc_bounds":
calculateGPA2();
break;
case "custom_font":
case "custom_font_skip_p":
loadCustomFont();
break;
case "remlogo":
case "disable_color_overlay":
case "condensed_cards":
case "full_width":
case "center_cards":
case "custom_styles":
case "hide_navbar":
applyAestheticChanges();
// Better Sidebar also hides the nav-toggle + breadcrumbs bar with
// an inline style (part of its layout). The "Hide Navigation
// Bar" sub-option is the single source of truth for that
// element, so live-apply/restore the inline style here too —
// setupBetterSidebar only sets it once, on first mount.
if (options.better_sidebar) {
const crumbsBar = document.querySelector(".ic-app-nav-toggle-and-crumbs");
if (options.hide_navbar === true) {
crumbsBar?.style.setProperty("display", "none");
} else {
crumbsBar?.style.removeProperty("display");
}
}
break;
case "hide_new_canvas":
watchNewCanvasButton();
break;
case "hide_sequence_footer":
watchSequenceFooter();
break;
case "global_search":
if (options.global_search) {
setupGlobalSearch();
} else {
removeGlobalSearch();
}
break;
case "customBackgroundScale":
applyCustomBackground();
break;
case "customBackgroundDaily":
applyCustomBackground();
removeNasaInfoOverlay();
break;
case "customBackgroundNasaDaily":
applyCustomBackground();
if (options.customBackgroundNasaDaily === true) {
createNasaInfoOverlay();
} else {
removeNasaInfoOverlay();
}
break;
case "fitImageToScreen":
applyCustomBackground();
break;
case "remind":
showExampleReminder();
break;
case "imageSize":
case "cardRoundness":
case "imageRoundness":
case "cardSpacing":
case "cardWidth":
case "cardHeight":
case "cardPadding":
case "card_grid":
case "card_grid_columns":
case "card_grid_rows":
case "card_grid_column_gap":
case "card_grid_row_gap":
case "card_grid_center_rows":
case "customCardStyles":
// Coalesce rapid card-style edits (e.g. holding the arrow keys on a
// number input) into a single applyAestheticChanges() call. Each
// storage onChanged event would otherwise re-run the dashboard style
// pass immediately; a ~150ms cooldown is barely noticeable but keeps
// the page from thrashing while the user is still adjusting values.
debouncedApplyAestheticChanges();
break;
case "customBackgroundLink":
applyCustomBackground();
break;
case "bg_opacity":
applyCustomBackground();
applyBetterSidebarContentPanel();
break;
case "bg_blur":
applyCustomBackground();
applyBetterSidebarContentPanel();
break;
case "sidebar_opacity":
case "sidebar_blur":
applyCustomBackground();
break;
case "card_transparency":
case "card_opacity":
case "card_blur":
applyCustomBackground();
break;
case "better_todo":
if (options.better_todo) {
setupBetterTodo();
} else {
window.location.reload();
}
break;
case "todo_progress_rings": {
// toggle progress rings immediately
const placeholder = document.getElementById("better-todo-progress-placeholder");
if (!placeholder) break;
if (progressRingsEnabled()) {
if (typeof assignments?.then === 'function') {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data, courseId);
renderProgressRings(placeholder, scopedData);
});
}
} else {
placeholder.innerHTML = "";
}
break;
}
case "better_sidebar":
// Better Sidebar mounts its whole layout on page load, so either
// direction of the toggle reloads the page for a clean mount/
// removal. The old-value guard keeps a re-save of the same value
// from looping the reload.
if (changes["better_sidebar"]?.oldValue !== options.better_sidebar) {
window.location.reload();
break;
}
if (options.better_sidebar) {
ensureBetterSidebar();
} else {
resetBetterSidebarLayout();
}
break;
case "grade_analytics":
watchGradeAnalytics();
break;
case "grade_analytics_zones":
// Colored 10% zones on the line chart — just redraw the charts.
if (gradeAnalyticsActive() && gaOpen && gaData) renderGradeAnalytics();
break;
case "quiz_safe_mode":
// Toggling safe mode changes which features run on quiz pages; reload
// so the gating is applied cleanly.
if (isQuizPage()) window.location.reload();
break;
case "sidebar_scale": {
const existingSidebar = document.getElementById("better-sidebar-container");
if (existingSidebar) {
const expander = existingSidebar.querySelector(".better-sidebar-expander");
updateSidebar(existingSidebar.dataset.expanded === "true", existingSidebar, expander);
}
break;
}
}
});
}
function resetBetterSidebarLayout() {
document.getElementById("header")?.style.removeProperty("display");
document.querySelector(".ic-Layout-wrapper")?.style.removeProperty("margin-left");
document.querySelector("#main")?.style.removeProperty("margin-left");
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("flex");
document.getElementById("not_right_side")?.style.removeProperty("min-width");
document.getElementById("right-side-wrapper")?.style.removeProperty("flex");
document.getElementById("right-side-wrapper")?.style.removeProperty("width");
document.getElementById("right-side-wrapper")?.style.removeProperty("max-width");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("display");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("align-items");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("flex");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("margin");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("padding");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("background");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("backdrop-filter");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("-webkit-backdrop-filter");
document.getElementById("left-side")?.style.removeProperty("display");
document.getElementById("left-side")?.style.removeProperty("padding-top");
document.getElementById("left-side")?.style.removeProperty("padding-left");
document.getElementById("section-tabs")?.style.removeProperty("padding-top");
document.getElementById("better-sidebar-container")?.remove();
clearBetterSidebarLayoutFix();
if (sidebarBadgeObserver) { sidebarBadgeObserver.disconnect(); sidebarBadgeObserver = null; }
if (sidebarBadgeSyncTimer) { clearTimeout(sidebarBadgeSyncTimer); sidebarBadgeSyncTimer = null; }
sidebarBadgeWatchRetries = 0;
}
function ensureBetterSidebar() {
if (!options.better_sidebar) return;
// Quiz safe mode: don't replace the Canvas sidebar on quiz pages.
if (quizSafeModeActive()) return;
if (document.querySelector("#better-sidebar-container")) return;
if (!document.querySelector("#wrapper") || !document.querySelector(".ic-Layout-contentWrapper")) return;
setupBetterSidebar(getSidebarLayoutMode());
}
async function applyCustomBackground() {
// Quiz safe mode: leave the quiz page background untouched.
if (quizSafeModeActive()) return;
// let style = document.querySelector("#DashboardCard_Container")
let style = document.querySelector("#canvasrefined-background") || document.createElement('style');
style.id = "canvasrefined-background";
const activeBackground = await getActiveCustomBackground();
if (!activeBackground) {
if (style.isConnected) style.remove();
return;
}
const backgroundScale = Number(activeBackground.scale) || 100;
const backgroundUrl = JSON.stringify(activeBackground.url);
const fitToScreen = options.fitImageToScreen === true;
// Opacity sliders (0-100). 100 = fully opaque surface, 0 = fully transparent
// so the background image shows through. Only emitted while a background is
// active, since transparency without an image just exposes the dark body.
const bgOpacity = Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)));
const sidebarOpacity = Math.max(0, Math.min(100, Number(options.sidebar_opacity ?? 100)));
const bgTransparent = 100 - bgOpacity;
const sidebarTransparent = 100 - sidebarOpacity;
// Blur sliders (px). Pairs with opacity: blur only has a visible effect when
// the surface is semi-transparent (opacity < 100) so the background behind
// shows through and gets blurred. Default 8px on content surfaces preserves
// the previous dashboard-header glass look; sidebar defaults to none.
const bgBlur = Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)));
const sidebarBlur = Math.max(0, Math.min(30, Number(options.sidebar_blur ?? 0)));
// Card transparency mirrors the content-panel glass effect (bg_opacity/
// bg_blur) but applies it to dashboard course cards (.ic-DashboardCard).
// Only active when the user explicitly enables it, since transparent cards
// over a busy background can hurt legibility.
const cardTransparency = options.card_transparency === true;
const cardOpacity = Math.max(0, Math.min(100, Number(options.card_opacity ?? 80)));
const cardBlur = Math.max(0, Math.min(30, Number(options.card_blur ?? 8)));
const cardTransparent = 100 - cardOpacity;
style.textContent = `
#wrapper {
background-image: url(${backgroundUrl}) !important;
background-repeat: no-repeat !important;
background-position: center center !important;
background-attachment: fixed !important;
}
@media (orientation: landscape) {
#wrapper { background-size: ${fitToScreen ? 'cover' : backgroundScale + '% auto'} !important; }
}
@media (orientation: portrait) {
#wrapper { background-size: cover !important; }
}
.ic-Dashboard-header__layout {
background: none !important;
/* backdrop-filter: blur(10px) !important; */
border-radius: 5px;
padding-left: 20px !important;
}
#dashboard_header_container {
margin-left: -35px !important;
margin-right: -35px !important;
box-sizing: border-box !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 10px !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
}
/* Dashboard list view (planner). Canvas paints each day block an
opaque theme color, so with a background image the whole list reads
as one solid slab that hides the image — unlike card view, where
the glass header and (optionally) translucent cards let it show
through. Give each day group the same glass treatment as the module
panels (color-mix tint + slider blur + rounded border) so the
background peeks through between the day cards. */
#dashboard-planner .planner-day,
#dashboard-planner .planner-empty-days {
background: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
padding: 8px 12px !important;
box-sizing: border-box !important;
}
/* Inner surfaces Canvas keeps opaque (the "Show N completed
item" facade, "Nothing Planned" filler, and the Today/Add To Do
header cluster that sits on the glass dashboard header bar):
flatten them so the glass behind shows through. The course-
grouping label instead gets a subtle chip behind the course name —
it sits over the course hero image, so without a backdrop the
text can be hard to read on busy images. */
#dashboard-planner .CompletedItemsFacade-styles__root,
#dashboard-planner .EmptyDays-styles__nothingPlanned,
#dashboard-planner-header .PlannerHeader-styles__root {
background: transparent !important;
}
#dashboard-planner .Grouping-styles__title {
background: var(--bcbackground-1) !important;
border-radius: 6px !important;
}
/* Item-row hover: subtle tint on the glass instead of Canvas's flat
gray, so rows feel alive on the translucent day cards. */
#dashboard-planner .planner-item:hover,
#dashboard-planner .Grouping-styles__heroHover:hover {
background: color-mix(in srgb, var(--bctext-0) 5%, transparent) !important;
border-radius: 8px !important;
}
#right-side-wrapper {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%);
border-radius: 5px;
}
/* Native left nav column: #left-side > #sticky-container.ic-sticky-frame
(the course/account/group menu links). Tint the whole #left-side column
rather than the inner .ic-sticky-frame, whose height only wraps its
links — the column spans the full viewport height like the other
sidebars, at the same bg_opacity/bg_blur as the Better Todo List
panel. Without this, a custom background (most visible in light mode)
shows through untinted behind the nav links. */
#left-side {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
}
/* Recent feedback lives in #right-side. The dark-mode CSS
(darkmodecss.js) recolors its text, but those rules are dark-mode-
only — so in light mode + custom background the sub-text (context,
grade, quote) keeps Canvas's default gray on the now-translucent
panel and becomes hard to read. Recolor to the theme text color so
it stays readable in both modes whenever a background is active.
Mirrors the dark-mode selectors; redundant (same value) in dark mode. */
.recent_feedback .event-details {
background: none !important;
}
#right-side .event-details .event-details__context,
#right-side .event-details .event-details__context *,
#right-side .recent_feedback .event-details p,
#right-side .recent_feedback .event-details span {
color: var(--bctext-0) !important;
}
.event-details strong {
color: var(--bctext-0) !important;
}
/* Native global nav sidebar. color-mix only accepts a solid color, so
gradient/image sidebars keep their existing look (rule is invalid and
ignored). At 100% opacity this is equivalent to var(--bcsidebar).
Sidebar blur only shows when sidebar opacity < 100.
The icon/text colors are recolored to var(--bcsidebar-text) to match
the background we just set — without this, light mode (where
--bcsidebar is the light default #e3e3e3) would leave institution-
themed light icons on a now-light background = white-on-white.
Mirrors the dark-mode rules in css/darkmodecss.js. */
.ic-app-header {
background: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important;
backdrop-filter: blur(${sidebarBlur}px) !important;
-webkit-backdrop-filter: blur(${sidebarBlur}px) !important;
}
.ic-app-header__menu-list-link svg,
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active svg {
fill: var(--bcsidebar-text) !important;
}
.menu-item-icon-container,
.ic-app-header__menu-list-link .menu-item__text,
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active .menu-item__text {
color: var(--bcsidebar-text) !important;
}
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active .ic-app-header__menu-list-link,
.ic-app-header__menu-list-link:hover {
background: #0000004f !important;
}
/* Better sidebar. The inline background-color is var(--bcsidebar), so the
!important here is required to override it. The same sidebar_opacity /
sidebar_blur sliders drive both surfaces, so whichever sidebar is
active (Better Sidebar when enabled, otherwise the native nav) picks
up the value. */
#better-sidebar-container {
background-color: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important;
backdrop-filter: blur(${sidebarBlur}px) !important;
-webkit-backdrop-filter: blur(${sidebarBlur}px) !important;
}
.header-bar {
background: none !important;
padding: 0 !important;
border: none !important;
}
.item-group-condensed,
.item-group-container {
background: transparent !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
/* box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important; */
}
#context_modules_sortable_container {
border: none !important;
background: none !important;
padding: 0 !important;
/* backdrop-filter: blur(0) !important; */
}
.item-group-condensed .ig-header,
.item-group-condensed .ig-row,
.item-group-container .ig-header,
.item-group-container .ig-row,
.item-group-condensed .header,
.item-group-container .header {
background: transparent !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
background: none !important;
border: none !important;
border-radius: 0 !important;
}
#assignments.ui-tabs-panel {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
}
/* InstUI flex View panel (emotion hash css-fiozi7-view--flex-flex)
ships a hardcoded opaque surface that sits untinted on top of the
background image. Give it the same glass treatment as the other
content panels: theme tint at the bg_opacity slider value plus the
bg_blur backdrop. */
.css-fiozi7-view--flex-flex {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
}
/* Inner InstUI Views of that panel (author row, title row, message
container...) keep their own opaque backgrounds, which read as
dark/light slabs covering the glass. Flatten them so the panel
reads as one glass surface — with exceptions:
- avatar circles (their backdrop is part of the picture),
- the whole role-pill subtree (Canvas pairs its white uppercase
text with a chip painted on the list items; flattening it left
white text on glass),
- base buttons: flattened to bare glass buttons instead. The
button root, its content span and its icon wrapper all go
transparent so the white label/icon read on the glass — this
also overrides the dark-mode baseButton__content chip, which
would otherwise double-stack inside the flattened panel. */
.css-fiozi7-view--flex-flex [class*="-view"]:not([class*="-avatar"]):not([data-testid="pill-container"]):not([data-testid="pill-container"] *):not([class*="-baseButton"]) {
background: none !important;
}
.css-fiozi7-view--flex-flex [class*="-baseButton"],
.css-fiozi7-view--flex-flex [class*="-baseButton"] [class*="-baseButton__content"],
.css-fiozi7-view--flex-flex [class*="-baseButton"] [class*="-baseButton__iconWrapper"] {
background: none !important;
border: none !important;
box-shadow: none !important;
}
/* De-dupe stacked surfaces: a nested copy of the panel (the header
renders one View inside another) must not add a second backdrop
blur over the outer glass. */
.css-fiozi7-view--flex-flex .css-fiozi7-view--flex-flex {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* The discussion threads toolbar (emotion hash css-1n94jrf-view,
themed opaque in dark mode) sits directly under the glass header
panel; with a custom background the two read as a double opaque
surface. Give the toolbar the same glass treatment so the area is
one continuous surface. */
.css-1n94jrf-view {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
padding: 5px !important;
}
#assignments {
padding-top: 0px !important;
padding-bottom: 0px !important;
padding-left: 10px !important;
padding-right: 10px !important;
}
${isCoursesIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
${isGroupsIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
/* Course content pages (sidebar layout "course": /courses/:id/* and
/profile): tint the #content.ic-Layout-contentMain panel so the
bg_opacity/bg_blur sliders have a surface to control even without Better
Sidebar. setupBetterSidebar only adds this panel when Better Sidebar is
on; this mirrors its inline values so the panel shows regardless. When
Better Sidebar is on, its inline !important overrides these (same values),
so this rule is inert in that case. */
${getSidebarLayoutMode() === "course" ? `
.ic-Layout-contentMain {
margin: 26px 38px 38px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 10px !important;
}
` : ""}
${isConversationsPage() ? `
.css-1nh4pc4-view-flexItem {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
.css-1nh4pc4-view-flexItem svg,
.css-1nh4pc4-view-flexItem svg * {
fill: currentColor !important;
stroke: currentColor !important;
color: var(--bctext-0) !important;
}
` : ""}
.item-group-condensed .ig-row.ig-published.no-estimated-duration {
color: var(--bctext-1) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 0 !important;
padding: 10px 12px !important;
}
.item-group-condensed .context_module_item,
.item-group-container .context_module_item {
background: transparent !important;
/* backdrop-filter: blur(10px) saturate(115%) !important;
-webkit-backdrop-filter: blur(10px) saturate(115%) !important; */
}
.item-group-condensed .context_module_item:hover,
.item-group-container .context_module_item:hover,
.item-group-condensed .context_module_item.context_module_item_hover,
.item-group-container .context_module_item.context_module_item_hover {
background: transparent !important;
border-radius: 10px !important;
}
.item-group-container {
background: transparent !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
}
.ig-header {
/* backdrop-filter: blur(10px) !important; */
}
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed[class~="context_module"] {
margin-bottom: 10px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
padding-top: 0 !important;
}
/* Module panels keep the slider blur on hover (previously a fixed 5px). */
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed[class~="context_module"],
.item-group-condensed.context_module:hover,
.item-group-condensed.context_module_item:hover,
.item-group-condensed.context_module.context_module_item_hover,
.item-group-condensed.context_module_item.context_module_item_hover {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
}
.canvasrefined-gpa-card,
.canvasrefined-gpa,
.ic-DashboardCard {
${cardTransparency
? `background: color-mix(in srgb, var(--bcbackground-0), transparent ${cardTransparent}%) !important;
backdrop-filter: blur(${cardBlur}px) saturate(120%) !important;
-webkit-backdrop-filter: blur(${cardBlur}px) saturate(120%) !important;`
: `background: var(--bcbackground-0) !important;`}
}
/* Card header strip (the course-nickname bar under the hero). Canvas
paints it a solid light color ($ic-color-light) and nothing overrides
that in light mode, so with card transparency on it reads as a solid
band across an otherwise translucent card. Mirror the card surface:
transparent cards drop the strip's background so the card's glass
(tint + blur already applied to .ic-DashboardCard) shows through;
opaque cards paint it the same solid theme color as the card body.
Dark mode already flattens this strip via darkmodecss.js, so this
is inert there (same value). */
.ic-DashboardCard__header_content {
${cardTransparency
? `background: none !important;`
: `background: var(--bcbackground-0) !important;`}
}
tr.student_assignment.assignment_graded.editable > * {
border:none!important
}`;
// TODO: liquid glass?
document.documentElement.appendChild(style);
}
function applyBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix") || document.createElement("style");
style.id = "canvasrefined-sidebar-layout-fix";
style.textContent = `
#wrapper,
.ic-Layout-wrapper,
#main {
margin-left: 0 !important;
}
`;
document.documentElement.appendChild(style);
}
function clearBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix");
if (style) style.remove();
}
let insertTimer;
function resetTimer() {
clearTimeout(insertTimer);
insertTimer = setTimeout(() => {
if (document.querySelectorAll(".ic-DashboardCard__link").length > 0) {
loadCardAssignments();
loadBetterTodo();
} else {
resetTimer();
}
}, 1);
}
function checkDashboardReady() {
const isDashboard = () => current_page == "/" || current_page == "" || /^\/courses\/(\d+)(?:\/|$)/.test(current_page);
const callback = (mutationList) => {
// Ignore attribute-only mutations; only structural (childList) changes matter here.
let hasChildList = false;
for (const mutation of mutationList) {
if (mutation.type === "childList") { hasChildList = true; break; }
}
if (!hasChildList) return;
if (isDashboard()) {
// Debounce: a single setup pass per burst of mutations.
if (dashboardReadyTimer) return;
dashboardReadyTimer = setTimeout(() => {
dashboardReadyTimer = null;
const c = document.querySelector("#DashboardCard_Container");
if (c) {
let cards = document.querySelectorAll(".ic-DashboardCard");
// Build a cheap signature of the current card set. The setup
// pass below mutates the cards' internals (assignment rows,
// grades, etc.) but never adds/removes the .ic-DashboardCard
// elements themselves, so the signature stays stable across
// our own mutations. It only changes when Canvas re-renders the
// dashboard (cards added/removed/reordered/replaced). Skipping
// when it's unchanged breaks the self-retriggering reflow loop.
let signature = cards.length + "";
for (let i = 0; i < cards.length; i++) {
const link = cards[i].querySelector(".ic-DashboardCard__link");
signature += "|" + (link ? link.getAttribute("href") : "");
}
// Canvas often re-renders the dashboard on a hard reload and
// replaces the .ic-DashboardCard nodes with fresh ones that have
// the same courses/links (so the signature is unchanged) but no
// longer carry our .canvasrefined-card-assignment marker. The
// signature guard alone would skip re-setup in that case, leaving
// card assignments empty until a popup toggle forces a reload.
// Re-run whenever any card is missing its marker too. This is safe
// from the self-retriggering reflow loop: after the pass every
// card has the marker, so our own subsequent mutation bursts skip.
let missingMarker = false;
for (let i = 0; i < cards.length; i++) {
if (!cards[i].querySelector(".canvasrefined-card-assignment")) {
missingMarker = true;
break;
}
}
if (signature !== lastDashboardCardSignature || missingMarker) {
lastDashboardCardSignature = signature;
changeGradientCards();
setupCardAssignments();
loadCardAssignments();
customizeCards(cards);
insertGrades();
loadDashboardNotes();
setupGPACalc();
showUpdateMsg();
createNasaInfoOverlay();
}
}
const rightSide = document.querySelector("#right-side");
if (rightSide && !rightSide.querySelector(".canvasrefined-todosidebar")) {
setupBetterTodo();
setupBetterSidebar(getSidebarLayoutMode());
}
if (options.better_sidebar) {
ensureBetterSidebar();
}
}, 0);
} else if (options.better_sidebar) {
// Throttle sidebar setup checks on non-dashboard (course) pages instead
// of calling ensureBetterSidebar() on every mutation burst.
if (sidebarReadyTimer) return;
sidebarReadyTimer = setTimeout(() => {
sidebarReadyTimer = null;
ensureBetterSidebar();
}, 100);
}
};
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function recieveMessage(request, sender, sendResponse) {
switch (request.message) {
case ("getCards"):
if (options["card_method_dashboard"] === true) {
getCardsFromDashboard().then(() => sendResponse(true)).catch(() => sendResponse(true));
} else {
getCards().then(() => sendResponse(true)).catch(() => sendResponse(true));
}
return true; // keep the message channel open for async sendResponse
case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break;
case ("getcolors"): getCardColors().then(colors => sendResponse(colors)); return true; // keep the message channel open for async sendResponse
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
case ("clearPlannerCache"):
// "Clear planner cache" (Report issue tab in the popup): drop
// the cache, re-fetch fresh planner data, and re-render every
// consumer in this tab.
(async () => {
try { await chrome.storage.local.remove(PLANNER_CACHE_KEY); } catch (e) { /* nothing stored */ }
if (options.assignments_due === true || options.better_todo === true) {
const items = await loadPlannerItems();
refreshPlannerConsumers(items);
}
sendResponse(true);
})();
return true; // keep the message channel open for async sendResponse
default: sendResponse(true);
}
}
function hexToRgb(hex) {
let match = (/#(.{2})(.{2})(.{2})/).exec(hex);
if (match) {
return { "r": parseInt(match[1], 16), "g": parseInt(match[2], 16), "b": parseInt(match[3], 16) };
}
}
function inspectDarkMode(withOutput = false) {
let output = "";
let bgcount = 0, textcount = 0, time = performance.now();
let bg0 = hexToRgb(options.dark_preset["background-0"]);
let bg1 = hexToRgb(options.dark_preset["background-1"]);
let txt = hexToRgb(options.dark_preset["text-0"]);
let bdr = hexToRgb(options.dark_preset["borders"]);
let lnk = hexToRgb(options.dark_preset["links"]);
document.querySelectorAll("*").forEach(el => {
let style = getComputedStyle(el);
let bgcolor = style.getPropertyValue("background").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)\) none/);
let selector = "class=." + el.className + ",id=#" + el.id;
if (bgcolor) {
const r = parseInt(bgcolor.groups["r"]);
const g = parseInt(bgcolor.groups["g"]);
const b = parseInt(bgcolor.groups["b"]);
if (r > 245 && g > 245 && b > 245 && !(r === bg0.r && g === bg0.g && b === bg0.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-0"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-0, color: text-0}\n";
bgcount++;
} else if (r > 225 && r < 245 && g > 225 && g < 245 && b > 225 && b < 245 && !(r === bg1.r && g === bg1.g && b === bg1.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-1"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-1, color: text-0}";
bgcount++;
}
}
let bordercolor = style.getPropertyValue("border-color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (bordercolor) {
const r = parseInt(bordercolor.groups["r"]);
const g = parseInt(bordercolor.groups["g"]);
const b = parseInt(bordercolor.groups["b"]);
if (r > 195 && g > 195 && b > 195 && !(r === bdr.r && g === bdr.g && b === bdr.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = "border-color:" + options.dark_preset["borders"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{border: borders}";
}
}
let text = style.getPropertyValue("color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (text) {
const r = parseInt(text.groups["r"]);
const g = parseInt(text.groups["g"]);
const b = parseInt(text.groups["b"]);
if (r <= 70 && g <= 70 && b <= 70 && !(r === txt.r && g === txt.g && b === txt.b)) {
el.style.cssText = "color:" + options.dark_preset["text-0"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{text: text-0}";
textcount++;
}
}
});
console.log("done fixing dark mode - time:", performance.now() - time, "total backgrounds changed: ", bgcount, ", total colors changed: ", textcount);
return { "selectors": output === "" ? "no gaps determined" : output, "time": performance.now() - time };
}
async function getCardColors() {
// Same display order changeColorPreset uses to APPLY palettes, so an
// exported theme's color list maps back onto the same courses when
// applied. Works in list mode too (API fallback inside getPaletteCards).
const { cards, apiColors } = await getPaletteCards();
if (cards.length === 0) return [];
return cards.map(card => card.el
? rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor)
: (apiColors["course_" + card.href.split("courses/")[1]] || "#ffffff"));
}
function getCardsFromDashboard() {
console.log("getting cards from dashboard")
const dashboard_cards = document.querySelectorAll(".ic-DashboardCard");
return new Promise(resolve => {
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
try {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (count >= (options["card_limit"] || 25)) return;
if (!cards[id]) {
newCards = true;
cards[id] = { "default": card.querySelector(".ic-DashboardCard__header-subtitle").textContent.substring(0, 20), "fullName": card.querySelector(".ic-DashboardCard__header-title")?.textContent?.trim() || "", "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": 100000 - count, "gr": null };
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
cards_3[id] = { "url": domain };
} else {
// backfill full name for cards created before this field existed
const full = card.querySelector(".ic-DashboardCard__header-title")?.textContent?.trim() || "";
if (full && cards[id].fullName !== full) {
cards[id].fullName = full;
newCards = true;
}
}
count++;
});
// there shouldn't be 0 cards
if (count === 0) return;
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (parseInt(key) === parseInt(id)) found = true;
});
}
if (found === false) {
console.log("Deleting " + key);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log("Error getting dashboard cards\n", e);
logError(e);
} finally {
if(newCards !== true) { resolve(); return; }
console.log(newCards ? "new cards found" : "");
chrome.storage.sync.set({ "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 }, () => resolve());
}
});
});
}
async function getCards(api = null) {
let dashboard_cards = api ? api : await getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`);
await new Promise(resolve => {
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
// sort cards by enrollment id (i think the higher the id, the more recent it is)
if (options["card_method_date"] === true) {
dashboard_cards.sort((a, b) => (b?.created_at) > (a?.created_at) ? 1 : -1);
} else {
dashboard_cards.sort((a, b) => (b?.enrollment_term_id || 0) - (a?.enrollment_term_id || 0));
}
try {
dashboard_cards.forEach(card => {
if (!card.course_code || count >= (options["card_limit"] || 25)) return;
let id = card.id;
if (!cards || !cards[id]) {
newCards = true;
cards[id] = { "default": card.course_code.substring(0, 20), "fullName": card.name || card.course_code || "", "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": card.enrollment_term_id || 0, "gr": null };
} else if (cards && cards[id]) {
newCards = true;
cards[id].default = card.course_code.substring(0, 20);
cards[id].fullName = card.name || card.course_code || cards[id].fullName || "";
cards[id].eid = card.enrollment_term_id || 0;
if (!cards[id].code) cards[id].code = "";
}
if (!cards_2 || !cards_2[id]) {
newCards = true;
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
}
if (!cards_3 || !cards_3[id]) {
newCards = true;
cards_3[id] = { "url": domain };
}
count++;
});
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
if (parseInt(key) === card.id) found = true;
});
}
if (found === false) {
console.log("Deleting " + key + " from custom_cards...", cards[key]);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log(e);
} finally {
chrome.storage.sync.set(newCards ? { "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 } : {}, () => resolve());
}
});
});
}
/*
Better todo list
*/
function convertToDueDate(dueAt) {
final = "due ";
let date = new Date(dueAt);
final += date.toLocaleString("en-US", { month: "short", day: "numeric" });
final += " at " + date.toLocaleString("en-US", { hour: "numeric", minute: "numeric", hour12: !options.todo_hr24 });
return final;
}
function updateIndicator(element) {
const indicator = document.getElementById("better-todo-indicator");
indicator.style.width = `${element.offsetWidth*2}px`;
indicator.style.left = `${element.offsetLeft - (element.offsetWidth * .5)}px`;
const buttons = ["announcement", "assignments", "completed"];
buttons.forEach(button => {
const btn = document.getElementById(`better-todo-${button}`);
if (btn == element) {
btn.firstElementChild.style.opacity = "1";
// btn.style.filter = "none";
}
else {
btn.firstElementChild.style.opacity = ".5";
// btn.style.filter = "grayscale(100%)";
}
})
}
// better todo html
betterTodoFilter = "tasks";
// Timeframe filter for the upcoming Tasks tab. "all" = no limit; otherwise
// items are limited to those due on/before now+range (which also keeps
// overdue items). Only affects the Tasks (upcoming) tab; announcements and
// completed are unaffected because their dates are in the past.
let betterTodoTimeframe = "all";
const BETTER_TODO_TIMEFRAME_DAYS = {
"1week": 7,
"2week": 14,
"month": 30,
"2month": 60,
};
// Announcements older than this are never shown in the Better Todo list (and
// never count toward the unread badge). Unlike assignments, an announcement
// stops being an actionable to-do once it's old: the planner cache keeps a
// full year of history (PLANNER_LOOKBACK_DAYS, needed for overdue
// assignments), so without this cutoff never-opened announcements from a
// year ago kept showing under "New" forever. Old announcements are still
// available on the course's own Announcements page.
const TODO_ANNOUNCEMENT_MAX_AGE_DAYS = 30;
// Look-ahead paging for the timeframe window (arrow buttons under the Tasks
// header). 0 = the current window (now through the timeframe cutoff, with
// overdue items kept). Each press of the right arrow advances one full
// window further out, e.g. a 1-week timeframe on offset 1 shows only items
// due in week 2 (the current week is skipped over).
let betterTodoTimeframeOffset = 0;
// null = show every class; a string courseId = only that class's tasks.
let betterTodoProgressFilter = null;
let domContainers = {};
// Resolve the persisted todo_timeframe option to a valid key ("all" when
// unset/unknown).
function getTodoTimeframeKey() {
return (options.todo_timeframe && Object.prototype.hasOwnProperty.call(BETTER_TODO_TIMEFRAME_DAYS, options.todo_timeframe)) ? options.todo_timeframe : "all";
}
// Better Todo timeframe filter, shared by the task list and the progress
// display so their counts always agree: keeps items due inside the current
// window. Offset 0 keeps everything on/before now+range (overdue items are
// before now, so they are kept too); offset N>0 shows only the window
// (now+N*range, now+(N+1)*range] so the current timeframe can be paged
// through with the look-ahead arrows. "all" keeps everything.
function applyTodoTimeframe(items) {
betterTodoTimeframe = getTodoTimeframeKey();
if (betterTodoTimeframe === "all") return items;
const days = BETTER_TODO_TIMEFRAME_DAYS[betterTodoTimeframe];
const now = Date.now();
const cutoff = now + ((betterTodoTimeframeOffset + 1) * days * 24 * 60 * 60 * 1000);
if (betterTodoTimeframeOffset <= 0) {
return items.filter(item => new Date(item.plannable_date).getTime() <= cutoff);
}
const windowStart = now + (betterTodoTimeframeOffset * days * 24 * 60 * 60 * 1000);
return items.filter(item => {
const t = new Date(item.plannable_date).getTime();
return t > windowStart && t <= cutoff;
});
}
// Date range for the timeframe pager under the Tasks header. The offset is
// measured in full windows from now, so the dates shown are the exact slice
// of items the list is currently filtered to.
function getTodoTimeframeWindow() {
const tf = getTodoTimeframeKey();
const days = tf === "all" ? 0 : BETTER_TODO_TIMEFRAME_DAYS[tf];
const offset = (tf === "all") ? 0 : betterTodoTimeframeOffset;
const now = Date.now();
const start = new Date(now + (offset * days * 24 * 60 * 60 * 1000));
const end = new Date(now + ((offset + 1) * days * 24 * 60 * 60 * 1000));
const fmt = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return { tf, range: fmt(start) + " \u2013 " + fmt(end) };
}
// Refresh the timeframe pager (arrows under the Tasks header) after every
// render: update its label/date range, disable the back arrow on the
// current window, and hide it entirely when there is no window to page
// ("all"). Shown on every tab — the pager pages time itself, so each tab
// shows its slice of the selected window.
function updateTodoTimeframeNav() {
const nav = document.getElementById("better-todo-timeframe-nav");
if (!nav) return;
const tfWindow = getTodoTimeframeWindow();
const paged = tfWindow.tf !== "all";
nav.style.display = paged ? "flex" : "none";
if (!paged) return;
const label = nav.querySelector("#better-todo-timeframe-label");
if (label) label.textContent = tfWindow.range;
const prev = nav.querySelector("#better-todo-timeframe-prev");
if (prev) {
const canPrev = betterTodoTimeframeOffset > 0;
prev.disabled = !canPrev;
prev.style.opacity = canPrev ? "1" : ".3";
prev.style.cursor = canPrev ? "pointer" : "default";
}
}
// Strict [start, end] bounds of the timeframe window the pager is currently
// showing, or null when the timeframe is "all" (no window).
function getTodoTimeframeBounds() {
const tf = getTodoTimeframeKey();
if (tf === "all") return null;
const span = BETTER_TODO_TIMEFRAME_DAYS[tf] * 24 * 60 * 60 * 1000;
const now = Date.now();
return { start: now + (betterTodoTimeframeOffset * span), end: now + ((betterTodoTimeframeOffset + 1) * span) };
}
// true when `courseId` is the dimmed-out class because another class is selected.
function progressFilterDim(courseId) {
return betterTodoProgressFilter != null && String(courseId) !== String(betterTodoProgressFilter);
}
// Canvas serves gradable work as several plannable types: assignments, quizzes,
// and graded discussions (plus extension-created planner notes/custom tasks).
// All of these are "tasks" for the Better Todo list; announcements are
// handled separately.
function isTodoTaskType(item) {
return item.plannable_type == "assignment"
|| item.plannable_type == "planner_note"
|| item.plannable_type == "quiz"
|| item.plannable_type == "discussion_topic";
}
// Make an element filter the todo list to one class on click. A no-op on
// course pages (where only one class is in scope anyway); toggles off when the
// active class is clicked again.
function attachProgressFilterClick(el, courseId) {
if (getCurrentCourseId() != null) { el.style.cursor = ''; el.onclick = null; return; }
el.style.cursor = 'pointer';
el.onclick = () => {
betterTodoProgressFilter = (String(betterTodoProgressFilter) === String(courseId)) ? null : String(courseId);
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
}
function formatDateForInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatTimeForInput(date) {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
/* Progress display mode, stored in options.todo_progress_rings.
Modes: "none", "rings", "rainbow", "lines", "line".
Legacy booleans normalize: true/undefined -> rings, false -> none. */
function getProgressRingMode() {
const v = options.todo_progress_rings;
if (v === false) return "none";
if (v === true || v === undefined || v === null) return "rings";
const allowed = ["none", "rings", "rainbow", "lines", "line"];
if (allowed.includes(v)) return v;
// migrate unreleased string values from the prior experimental set
if (v === "per_course") return "rings";
if (v === "combined" || v === "segmented") return "line";
return "rings";
}
function progressRingsEnabled() {
return getProgressRingMode() !== "none";
}
function courseRingColor(courseId, idx) {
return options.custom_cards_3?.[String(courseId)]?.color
|| options.custom_cards_3?.[courseId]?.color
|| `hsl(${(idx * 60) % 360} 70% 50%)`;
}
function courseRingLabel(courseId) {
const card = options.custom_cards?.[String(courseId)] || options.custom_cards?.[courseId];
return card?.default || `Course ${courseId}`;
}
// Planner items for courses the user has hidden from their dashboard should
// not appear in the Better Todo list or its progress display. Personal
// tasks (planner notes with no course) are always kept.
function isCourseHidden(courseId) {
if (courseId === undefined || courseId === null) return false;
const cards = options.custom_cards || {};
const card = cards[String(courseId)] || cards[courseId];
return !!card && card.hidden === true;
}
function filterHiddenCourses(data) {
return data.filter(item => {
const cid = item.course_id || item.context_id || item?.plannable?.course_id;
return !isCourseHidden(cid);
});
}
// Build scoped data for the Better Todo list: drop hidden courses, then (on
// a course page) restrict to the current course.
function getTodoScopedData(data, courseId) {
const visible = filterHiddenCourses(data);
if (!courseId) return visible;
return visible.filter(item => {
const itemCourseId = parseInt(item.course_id || item.context_id || item?.plannable?.course_id);
return itemCourseId === courseId;
});
}
// Returns a Map of courseId (string) -> dashboard position index, read from
// the live dashboard card DOM order. Empty when not on the dashboard. Used
// to order the progress display the same way the user ordered their cards.
function getDashboardCourseOrder() {
const order = new Map();
document.querySelectorAll('.ic-DashboardCard').forEach((card, idx) => {
const id = getCardId(card);
if (id && id !== -1 && !order.has(String(id))) order.set(String(id), idx);
});
return order;
}
// Keep the centered % / count text clear of the progress graphics (the rings'
// center hole and the rainbow's bowl). The text block is measured after each
// render; if it would cross into the strokes its fonts are scaled down, and
// when the hole is really tight the count line is dropped before the % is
// allowed to shrink below readable size. Font sizes reset to the defaults on
// every render so the text grows back when there is room again.
// `neededRadius(hw, hh)` returns the distance from the hole's center to the
// farthest text corner; the text fits when that is <= availableRadius.
function fitProgressOverlayText(overlay, neededRadius, availableRadius) {
const textWrap = overlay?.firstElementChild;
const pct = overlay?.querySelector('.canvasrefined-progress-percent');
const cnt = overlay?.querySelector('.canvasrefined-progress-count');
if (!textWrap || !pct || !cnt || textWrap === pct || textWrap === cnt) return;
// Undo any shrink applied by a previous render before measuring (these
// are the default sizes the overlays are created with).
pct.style.fontSize = '20px';
cnt.style.fontSize = '12px';
cnt.style.display = '';
if (!availableRadius || availableRadius <= 0) return;
let w = textWrap.offsetWidth;
let h = textWrap.offsetHeight;
if (!w || !h) return;
let needed = neededRadius(w / 2, h / 2);
if (needed <= availableRadius) return;
let scale = availableRadius / needed;
if (20 * scale < 11) {
// Too tight for both lines: drop the count and re-fit the % alone.
cnt.style.display = 'none';
w = textWrap.offsetWidth;
h = textWrap.offsetHeight;
needed = neededRadius(w / 2, h / 2);
if (needed <= availableRadius) return;
scale = availableRadius / needed;
}
pct.style.fontSize = `${Math.max(10, Math.round(20 * scale))}px`;
if (cnt.style.display !== 'none') cnt.style.fontSize = `${Math.max(9, Math.round(12 * scale))}px`;
// Final safety: if the readable-size floors above still don't fit, drop
// the count line so the % is guaranteed to clear the strokes.
if (neededRadius(textWrap.offsetWidth / 2, textWrap.offsetHeight / 2) > availableRadius) {
cnt.style.display = 'none';
}
}
// Mode "rings": concentric rings, one per course, each filled by completion.
function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const cy = size / 2;
const padding = 2;
const outerRadius = Math.floor((size / 2) - padding);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;';
const textWrap = document.createElement('div');
textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);';
textWrap.innerHTML = ``;
overlay.appendChild(textWrap);
wrapper.appendChild(overlay);
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const startRadius = outerRadius - stroke / 2;
// Keep the center hole big enough for the % / count text so the numbers
// never sit on top of the ring strokes (fitProgressOverlayText shrinks the
// text as a safety net for unusually wide labels).
const minCenterRadius = 44;
const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius;
let adjustFactor = 1;
if (requiredSpace > startRadius) {
adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement);
}
let innerEdge = startRadius - stroke / 2;
shown.forEach((entry, idx) => {
const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
innerEdge = Math.min(innerEdge, radius - stroke / 2);
const circumference = 2 * Math.PI * radius;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
let bg = svg.querySelector(`circle[data-idx='${idx}'][data-role='bg']`);
if (!bg) {
bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('data-idx', String(idx));
bg.setAttribute('data-role', 'bg');
bg.classList.add('canvasrefined-ring-bg');
svg.appendChild(bg);
}
bg.setAttribute('cx', String(cx));
bg.setAttribute('cy', String(cy));
bg.setAttribute('r', String(radius));
bg.setAttribute('stroke', color);
bg.setAttribute('stroke-opacity', '0.25');
bg.setAttribute('stroke-width', String(stroke));
bg.setAttribute('fill', 'none');
bg.removeAttribute('stroke-dasharray');
bg.removeAttribute('stroke-dashoffset');
bg.removeAttribute('transform');
let fg = svg.querySelector(`circle[data-idx='${idx}'][data-role='fg']`);
const dasharrayVal = circumference.toFixed(3);
const target = (circumference * (1 - prog)).toFixed(3);
if (!fg) {
fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
fg.setAttribute('data-idx', String(idx));
fg.setAttribute('data-role', 'fg');
fg.classList.add('canvasrefined-progress-ring');
fg.setAttribute('stroke-linecap', 'round');
fg.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
fg.setAttribute('stroke-dasharray', dasharrayVal);
fg.setAttribute('stroke-dashoffset', dasharrayVal); // start empty
fg.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(fg);
}
fg.setAttribute('cx', String(cx));
fg.setAttribute('cy', String(cy));
fg.setAttribute('r', String(radius));
fg.setAttribute('stroke', color);
fg.setAttribute('stroke-width', String(stroke));
fg.setAttribute('fill', 'none');
fg.setAttribute('stroke-dasharray', dasharrayVal);
const dim = progressFilterDim(entry.courseId);
bg.style.transition = 'opacity .3s ease';
bg.style.opacity = dim ? '0.25' : '';
fg.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => fg.setAttribute('stroke-dashoffset', target)));
// transparent hit band on top so the whole ring is easy to click;
// width tracks the ring spacing so adjacent bands don't overlap.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
let hit = svg.querySelector(`circle[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('cx', String(cx));
hit.setAttribute('cy', String(cy));
hit.setAttribute('r', String(radius));
hit.setAttribute('stroke-width', String(Math.max(stroke, step)));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { bg.style.opacity = '0.8'; fg.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
bg.style.opacity = d ? '0.25' : '';
fg.style.opacity = d ? '0.3' : '';
};
});
// Shrink the % / count text if it would reach the innermost ring.
fitProgressOverlayText(overlay, (hw, hh) => Math.hypot(hw, hh), Math.max(0, innerEdge - 2));
const maxIdx = shown.length - 1;
svg.querySelectorAll('circle').forEach(c => {
const idx = parseInt(c.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) c.remove();
});
}
// Mode "rainbow": like "rings" (one arc per class) but condensed into a top
// half-circle and colored with a rainbow palette instead of course colors.
function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const pad = 2;
const outerRadius = Math.max(20, Math.floor(size / 2) - stroke / 2 - pad);
// diameter line; arcs bulge UPWARD from here (into y < baseY)
const baseY = outerRadius + stroke / 2 + pad;
const svgHeight = Math.ceil(baseY + stroke / 2 + 2);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(svgHeight));
svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`);
// shrink spacing if too many classes would overflow the inner radius;
// keep the inner bowl big enough for the % / count text so the numbers
// never sit on top of the arcs (fitProgressOverlayText shrinks the text
// as a safety net for unusually wide labels).
const minInnerRadius = 56;
const requiredSpace = (ringCount - 1) * decrement;
let adjustFactor = 1;
if (requiredSpace > outerRadius - minInnerRadius) {
adjustFactor = (outerRadius - minInnerRadius) / Math.max(1, (ringCount - 1) * decrement);
}
shown.forEach((entry, idx) => {
const radius = outerRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
// sweep-flag 1 => arc bulges UPWARD (top semicircle), drawn left -> right
const arcPath = `M ${cx - radius} ${baseY} A ${radius} ${radius} 0 0 1 ${cx + radius} ${baseY}`;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
// track: full semicircle, faded class color
let track = svg.querySelector(`path[data-idx='${idx}'][data-role='bg']`);
if (!track) {
track = document.createElementNS('http://www.w3.org/2000/svg', 'path');
track.setAttribute('data-idx', String(idx));
track.setAttribute('data-role', 'bg');
track.setAttribute('fill', 'none');
track.setAttribute('stroke-linecap', 'round');
svg.appendChild(track);
}
track.setAttribute('d', arcPath);
track.setAttribute('stroke', color);
track.setAttribute('stroke-opacity', '0.25');
track.setAttribute('stroke-width', String(stroke));
track.removeAttribute('stroke-dasharray');
track.removeAttribute('stroke-dashoffset');
// progress arc: completed portion drawn from the left, via pathLength=100
let progArc = svg.querySelector(`path[data-idx='${idx}'][data-role='fg']`);
const targetOff = (100 * (1 - prog)).toFixed(3);
if (!progArc) {
progArc = document.createElementNS('http://www.w3.org/2000/svg', 'path');
progArc.setAttribute('data-idx', String(idx));
progArc.setAttribute('data-role', 'fg');
progArc.setAttribute('fill', 'none');
progArc.setAttribute('stroke-linecap', 'round');
progArc.setAttribute('pathLength', '100');
progArc.setAttribute('stroke-dasharray', '100 100');
progArc.setAttribute('stroke-dashoffset', '100'); // start empty
progArc.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(progArc);
}
progArc.setAttribute('d', arcPath);
progArc.setAttribute('stroke', color);
progArc.setAttribute('stroke-width', String(stroke));
progArc.setAttribute('stroke-dasharray', '100 100');
const dim = progressFilterDim(entry.courseId);
track.style.transition = 'opacity .3s ease';
track.style.opacity = dim ? '0.25' : '';
progArc.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => progArc.setAttribute('stroke-dashoffset', targetOff)));
// transparent hit arc on top so the whole arc is easy to click.
let hit = svg.querySelector(`path[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('stroke-linecap', 'round');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('d', arcPath);
hit.setAttribute('stroke-width', String(stroke + 6));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { track.style.opacity = '0.8'; progArc.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
track.style.opacity = d ? '0.25' : '';
progArc.style.opacity = d ? '0.3' : '';
};
});
// drop arcs for classes no longer shown
const maxIdx = shown.length - 1;
svg.querySelectorAll('path').forEach(p => {
const idx = parseInt(p.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) p.remove();
});
// Overlay the percent/count text INSIDE the rainbow's semicircle hole
// (the empty bowl bounded by the INNERMOST arc) instead of beneath it or
// up among the arcs. The SVG is centered in the wrapper, so an absolutely-
// positioned overlay covering the wrapper with flex centering aligns the
// text to the SVG's horizontal center. Vertically we target the centroid of
// the innermost semicircle (a touch above the diameter line) so the text
// sits in the arc-free pocket near the bottom rather than near the apexes
// of the inner arcs, where it would overlap the rainbow strokes.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
const innerRadius = ringCount > 0 ? Math.max(1, outerRadius - (ringCount - 1) * step) : outerRadius;
const holeCenterY = baseY - (4 * innerRadius) / (3 * Math.PI) + 6;
const nudge = Math.round(holeCenterY - svgHeight / 2);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = `position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;transform:translateY(${nudge}px);`;
// Same textWrap structure as rings mode so fitProgressOverlayText
// measures the whole percent+count block, not just one line.
const textWrap = document.createElement('div');
textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);';
textWrap.innerHTML = ``;
overlay.appendChild(textWrap);
wrapper.appendChild(overlay);
} else {
overlay.style.transform = `translateY(${nudge}px)`;
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
// Shrink the % / count text if any corner would cross the innermost arc.
// The text is centered at (cx, holeCenterY); the bowl is the semicircle
// of innerRadius around (cx, baseY), so check the farthest text corner
// against the bowl's inner edge.
const bowlRadius = Math.max(0, innerRadius - stroke / 2 - 2);
fitProgressOverlayText(overlay, (hw, hh) => {
const dv = Math.max(
Math.abs(baseY - holeCenterY + hh),
Math.abs(baseY - holeCenterY - hh)
);
return Math.hypot(hw, dv);
}, bowlRadius);
}
// Mode "lines": one horizontal bar per course, each with its own %.
function renderProgressLines(wrapper, shown) {
let list = wrapper.querySelector('.canvasrefined-progress-lines');
if (!list) {
list = document.createElement('div');
list.className = 'canvasrefined-progress-lines';
list.style.cssText = 'display:flex;flex-direction:column;gap:8px;width:100%;box-sizing:border-box;';
wrapper.appendChild(list);
}
while (list.children.length > shown.length) list.lastChild.remove();
shown.forEach((entry, idx) => {
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const pct = Math.round(prog * 100);
const color = courseRingColor(entry.courseId, idx);
let row = list.children[idx];
if (!row) {
row = document.createElement('div');
row.className = 'canvasrefined-progress-line';
row.style.cssText = 'display:flex;flex-direction:column;gap:3px;width:100%;';
row.innerHTML = `
`;
list.appendChild(row);
}
const labelEl = row.querySelector('.cr-pl-label');
labelEl.textContent = courseRingLabel(entry.courseId);
labelEl.style.color = color;
row.querySelector('.cr-pl-pct').textContent = `${pct}% (${entry.completed}/${entry.total})`;
const track = row.children[1];
track.style.background = `color-mix(in srgb, ${color} 25%, transparent)`;
const fill = row.querySelector('.cr-pl-fill');
fill.style.background = color;
if (!fill.dataset.init) {
fill.dataset.init = '1';
requestAnimationFrame(() => requestAnimationFrame(() => fill.style.width = `${pct}%`));
} else {
fill.style.width = `${pct}%`;
}
row.style.transition = 'opacity .3s ease';
row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : '';
attachProgressFilterClick(row, entry.courseId);
row.onmouseenter = () => { row.style.opacity = '0.8'; };
row.onmouseleave = () => { row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : ''; };
});
}
// Mode "line": one horizontal bar where each class's COMPLETED portion is packed
// to the left (full course color) and its UNCOMPLETED portion to the right
// (faded course color), with no gaps between segments. Overall % shown above.
function renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent) {
let box = wrapper.querySelector('.canvasrefined-progress-oneline');
if (!box) {
box = document.createElement('div');
box.className = 'canvasrefined-progress-oneline';
box.style.cssText = 'display:flex;flex-direction:column;gap:5px;width:100%;box-sizing:border-box;';
wrapper.appendChild(box);
}
let head = box.querySelector('.cr-ol-head');
if (!head) {
head = document.createElement('div');
head.className = 'cr-ol-head';
head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--bctext-0);';
head.innerHTML = `Overall`;
box.appendChild(head);
}
box.querySelector('.cr-ol-pct').textContent = `${percent}% (${completedAll}/${totalAll})`;
// Clicking the "Overall" header clears the active class filter.
if (getCurrentCourseId() == null) {
head.style.cursor = betterTodoProgressFilter != null ? 'pointer' : '';
head.onclick = () => {
if (betterTodoProgressFilter == null) return;
betterTodoProgressFilter = null;
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
} else {
head.style.cursor = '';
head.onclick = null;
}
let bar = box.querySelector('.cr-ol-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'cr-ol-bar';
// position:relative so absolutely-positioned segments anchor to it
bar.style.cssText = 'position:relative;width:100%;height:14px;border-radius:999px;overflow:hidden;background:var(--bcbackground-1);';
box.appendChild(bar);
}
// Build the segment list left -> right: ALL completed blocks first (each
// class's full course color), then ALL remaining blocks (each class's faded
// course color). This keeps every completed segment on the left and every
// remaining segment on the right. Consecutive segments share edges, so
// there are no gaps between them.
const segs = [];
let left = 0;
// First pass: completed blocks for every class (left side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const doneW = totalAll === 0 ? 0 : (entry.completed / totalAll) * 100;
if (doneW > 0) { segs.push({ left, w: doneW, bg: color, courseId: entry.courseId }); left += doneW; } // completed: full color
});
// Second pass: remaining blocks for every class (right side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const remW = totalAll === 0 ? 0 : ((entry.total - entry.completed) / totalAll) * 100;
if (remW > 0) { segs.push({ left, w: remW, bg: `color-mix(in srgb, ${color} 25%, transparent)`, courseId: entry.courseId }); left += remW; } // remaining: faded
});
while (bar.children.length > segs.length) bar.lastChild.remove();
segs.forEach((seg, idx) => {
let el = bar.children[idx];
if (!el) {
el = document.createElement('div');
el.className = 'cr-ol-seg';
el.style.cssText = 'position:absolute;top:0;height:100%;transition:left .6s ease,width .6s ease,background .3s ease,opacity .3s ease;';
bar.appendChild(el);
}
el.style.left = `${seg.left}%`;
el.style.width = `${seg.w}%`;
el.style.background = seg.bg;
el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : '';
attachProgressFilterClick(el, seg.courseId);
el.onmouseenter = () => { el.style.opacity = '0.8'; };
el.onmouseleave = () => { el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : ''; };
});
}
function renderProgressRings(container, scopedData) {
const mode = getProgressRingMode();
if (mode === "none") { container.innerHTML = ""; return; }
// Apply the same timeframe window the list pages through so the counts
// in the display match what's shown below it. Unlike the Tasks list —
// which keeps every overdue item — finished work from before the window
// is history, not current workload, so it never counts. Unfinished items
// from before the window still count on the current window (offset 0)
// because the Tasks tab shows them as overdue; later windows are strict
// slices, so the backlog only appears on the current one.
const taskItems = scopedData.filter(item => isTodoTaskType(item));
const bounds = getTodoTimeframeBounds();
let allAssignments;
if (!bounds) {
allAssignments = taskItems;
} else {
const isDone = (item) => (item.submissions?.submitted || item.planner_override?.marked_complete === true) && !isPinnedIncomplete(item);
allAssignments = taskItems.filter(item => {
const t = new Date(item.plannable_date).getTime();
if (t > bounds.start && t <= bounds.end) return true;
return betterTodoTimeframeOffset === 0 && t <= bounds.start && !isDone(item);
});
}
const groups = {};
allAssignments.forEach(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
groups[cid] = groups[cid] || [];
groups[cid].push(item);
});
const entries = Object.keys(groups).map(cid => {
const arr = groups[cid];
const completed = arr.filter(it => (it.submissions?.submitted || it.planner_override?.marked_complete) && !isPinnedIncomplete(it)).length;
return { courseId: cid, total: arr.length, completed };
}).filter(e => e.total > 0);
if (!entries.length) {
// Don't just blank the display: an empty window (e.g. after advancing
// the pager to a week with nothing due) previously made the whole
// progress display disappear, which read as a bug. Show a quiet
// empty state instead.
container.innerHTML = "";
const empty = document.createElement('div');
empty.className = 'canvasrefined-progress-empty';
empty.style.cssText = 'color:var(--bctext-0);opacity:.7;font-size:12px;padding:6px 0;text-align:center;';
empty.textContent = bounds ? 'No tasks in this timeframe' : 'No tasks yet';
container.appendChild(empty);
return;
}
// Order courses to match the user's dashboard card order. Courses that
// aren't on the dashboard (personal tasks, dropped courses) sort after
// dashboard courses, keeping their relative order; ties fall back to
// most assignments first so the display stays stable.
const dashboardOrder = getDashboardCourseOrder();
entries.sort((a, b) => {
const ai = dashboardOrder.has(a.courseId) ? dashboardOrder.get(a.courseId) : Infinity;
const bi = dashboardOrder.has(b.courseId) ? dashboardOrder.get(b.courseId) : Infinity;
if (ai !== bi) return ai - bi;
return b.total - a.total;
});
const shown = entries.slice(0, 6);
const totalAll = shown.reduce((s, e) => s + e.total, 0);
const completedAll = shown.reduce((s, e) => s + e.completed, 0);
const percent = totalAll === 0 ? 0 : Math.round((completedAll / totalAll) * 100);
// wrapper reused across renders; clear on mode switch so each mode rebuilds fresh DOM
let wrapper = container.querySelector('.canvasrefined-progress-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'canvasrefined-progress-wrapper';
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;position:relative;width:100%;box-sizing:border-box;';
container.appendChild(wrapper);
}
if (wrapper.dataset.mode !== mode) {
wrapper.innerHTML = '';
wrapper.dataset.mode = mode;
}
if (mode === "rings") {
renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "rainbow") {
renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "lines") {
renderProgressLines(wrapper, shown);
} else if (mode === "line") {
renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent);
}
}
function buildPlannerNotePayload(form) {
const title = form.querySelector("#better-todo-new-task-title")?.value?.trim();
const details = form.querySelector("#better-todo-new-task-details")?.value?.trim();
const courseIdRaw = form.querySelector("#better-todo-new-task-course")?.value;
const dateValue = form.querySelector("#better-todo-new-task-date")?.value;
const timeValue = form.querySelector("#better-todo-new-task-time")?.value;
const link = form.querySelector("#better-todo-new-task-link")?.value?.trim() || "";
if (!title) {
throw new Error("Task title is required.");
}
if (!dateValue || !timeValue) {
throw new Error("Please choose both a date and time.");
}
const localDateTime = new Date(`${dateValue}T${timeValue}:00`);
if (Number.isNaN(localDateTime.getTime())) {
throw new Error("Invalid task date.");
}
return {
title,
details,
link,
courseId: courseIdRaw ? parseInt(courseIdRaw) : null,
// Canvas accepts local timestamp strings more reliably than UTC ISO strings for planner notes.
todoDate: `${dateValue}T${timeValue}:00`,
};
}
async function createCanvasPlannerNote(payload) {
const csrfToken = CSRFtoken();
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
};
if (payload.details) plannerNote.details = payload.details;
if (payload.courseId) plannerNote.course_id = payload.courseId;
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ planner_note: plannerNote }),
},
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("planner_note[title]", plannerNote.title);
formBody.set("planner_note[todo_date]", plannerNote.todo_date);
if (plannerNote.details) formBody.set("planner_note[details]", plannerNote.details);
if (plannerNote.course_id) formBody.set("planner_note[course_id]", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task creation.";
for (const attempt of attempts) {
const response = await fetch(domain + "/api/v1/planner_notes", {
method: "POST",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task creation.");
}
/* Custom task links: Canvas planner notes have no link field, so store a
user link in sync storage keyed by the note id. */
function getCustomTaskLinks() {
return (options && options.custom_task_links) || {};
}
function getCustomTaskLinkId(item) {
return item?.plannable_id ?? item?.plannable?.id ?? null;
}
function normalizeTaskLink(link) {
if (!link) return "";
link = String(link).trim();
if (!link) return "";
if (/^https?:\/\//i.test(link)) return link;
if (link.startsWith("//")) return "https:" + link;
return domain + (link.startsWith("/") ? link : "/" + link);
}
function customTaskHref(item) {
const id = getCustomTaskLinkId(item);
const links = getCustomTaskLinks();
if (id != null && links[String(id)]) {
return normalizeTaskLink(links[String(id)]);
}
const courseId = item?.course_id || item?.plannable?.course_id || item?.context_id;
if (courseId) return `${domain}/courses/${courseId}`;
return `${domain}/`;
}
function saveCustomTaskLink(noteId, link) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
const key = String(noteId);
if (link && String(link).trim()) {
links[key] = String(link).trim();
} else {
delete links[key];
}
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
function deleteCustomTaskLink(noteId) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
delete links[String(noteId)];
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
async function updateCanvasPlannerNote(id, payload) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
// Canvas's planner_notes update endpoint permits FLAT parameters
// (title, details, course_id, todo_date) — NOT nested under planner_note.
// Sending nested params is silently ignored, so note.update({}) runs and
// returns 200 with the unchanged note, making edits appear to "not save".
// Always include details (even empty) so the field can be cleared.
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
details: payload.details || "",
// Sending course_id as empty string disassociates the note from its course.
course_id: payload.courseId || "",
};
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("title", plannerNote.title);
formBody.set("todo_date", plannerNote.todo_date);
formBody.set("details", plannerNote.details);
formBody.set("course_id", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task update.";
for (const attempt of attempts) {
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "PUT",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task update.");
}
async function deleteCanvasPlannerNote(id) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "DELETE",
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
});
if (response.status === 200 || response.status === 201 || response.status === 204) {
return true;
}
let lastError = "Canvas rejected task deletion.";
try {
const errData = await response.json();
if (errData?.errors?.length) lastError = errData.errors.join(" ");
else if (errData?.message) lastError = errData.message;
} catch (_) { /* ignore */ }
throw new Error(lastError);
}
/* Scroll a task form field into view, leaving room below for the native date/time picker. */
function scrollTodoIntoView(el, smooth = true) {
if (!el) return;
const sidebar = document.getElementById("right-side-wrapper");
const style = sidebar ? getComputedStyle(sidebar) : null;
const scrollsSidebar = sidebar &&
(style.overflowY === "auto" || style.overflowY === "scroll") &&
sidebar.scrollHeight > sidebar.clientHeight;
const behavior = smooth ? "smooth" : "auto";
if (scrollsSidebar) {
const rect = el.getBoundingClientRect();
const sRect = sidebar.getBoundingClientRect();
const elTop = rect.top - sRect.top + sidebar.scrollTop;
// Keep the element near the top so the picker below it stays on screen.
const target = elTop - (sidebar.clientHeight - rect.height) * 0.3;
sidebar.scrollTo({ top: Math.max(0, target), behavior });
} else {
el.scrollIntoView({ block: "center", behavior });
}
}
function fillTaskCourseOptions(courseSelect) {
const cards = options.custom_cards || {};
const courseColors = options.custom_cards_3 || {};
const currentCourseId = getCurrentCourseId();
// Hidden courses should not be offered as a target for custom tasks.
const entries = Object.entries(cards)
.filter(([, card]) => card?.hidden !== true)
.map(([id, card]) => ({
id,
label: card?.default || `Course ${id}`,
color:
courseColors?.[String(id)]?.color ??
courseColors?.[id]?.color ??
"#c7cdd1",
}))
.sort((a, b) => a.label.localeCompare(b.label));
courseSelect.innerHTML = '';
courseSelect.options[0].dataset.color = "#c7cdd1";
entries.forEach(entry => {
const option = makeElement("option", courseSelect, {
value: entry.id,
textContent: entry.label,
});
option.dataset.color = entry.color;
option.style.color = entry.color;
if (currentCourseId && String(currentCourseId) === String(entry.id)) {
option.selected = true;
}
});
}
function updateTaskCourseSelectColor(courseSelect) {
const selectedOption = courseSelect?.options?.[courseSelect.selectedIndex];
const color = selectedOption?.dataset?.color || "#c7cdd1";
courseSelect.style.borderLeft = `4px solid ${color}`;
courseSelect.style.paddingLeft = "8px";
}
function ensureTodoTaskMenu(location, feedbackElement) {
let actionsRow = location.querySelector("#better-todo-actions-row");
if (!actionsRow) {
actionsRow = makeElement("div", location, {
id: "better-todo-actions-row",
style: "display:flex;flex-direction:column;gap:8px;margin-top:14px;",
});
const addTaskButton = makeElement("button", actionsRow, {
id: "better-todo-add-task-btn",
className: "canvasrefined-custom-btn",
textContent: "+ Add Task",
style: "width:100%;padding:6px 8px;cursor:pointer;",
});
const menu = makeElement("div", actionsRow, {
id: "better-todo-add-task-menu",
className: "canvasrefined-add-assignment",
});
menu.innerHTML = `
`;
const today = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(today);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(today);
const courseSelect = menu.querySelector("#better-todo-new-task-course");
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
courseSelect.addEventListener("change", () => updateTaskCourseSelectColor(courseSelect));
addTaskButton.addEventListener("click", () => {
const willOpen = !menu.classList.contains("canvasrefined-custom-open");
menu.classList.toggle("canvasrefined-custom-open");
if (willOpen) {
resetTaskFormToCreate(menu);
// Scroll the form up so the picker stays on screen.
scrollTodoIntoView(menu, true);
}
});
const submitTask = async () => {
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
if (deleteButton) deleteButton.disabled = true;
try {
const payload = buildPlannerNotePayload(menu);
const editingId = menu.dataset.editingId || null;
if (editingId) {
await updateCanvasPlannerNote(editingId, payload);
saveCustomTaskLink(editingId, payload.link);
status.textContent = "Task updated.";
} else {
const created = await createCanvasPlannerNote(payload);
const newId = created?.id;
if (newId != null) saveCustomTaskLink(newId, payload.link);
status.textContent = "Task created.";
}
status.style.color = "#198754";
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not save task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
if (deleteButton) deleteButton.disabled = false;
}
};
menu.querySelector("#better-todo-add-task-submit").addEventListener("click", submitTask);
// Close (×) button: dismiss the form without creating/editing a task.
menu.querySelector("#better-todo-add-task-close")?.addEventListener("click", () => {
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
});
// Reposition the field on focus so the picker opens on screen.
["#better-todo-new-task-date", "#better-todo-new-task-time"].forEach((sel) => {
const input = menu.querySelector(sel);
input?.addEventListener("focus", () => scrollTodoIntoView(input, false));
});
menu.querySelector("#better-todo-add-task-delete").addEventListener("click", async () => {
const editingId = menu.dataset.editingId || null;
if (!editingId) return;
if (!confirm("Delete this custom task? This cannot be undone.")) return;
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
deleteButton.disabled = true;
try {
await deleteCanvasPlannerNote(editingId);
deleteCustomTaskLink(editingId);
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not delete task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
deleteButton.disabled = false;
}
});
}
if (feedbackElement) {
if (actionsRow.nextSibling !== feedbackElement) {
location.insertBefore(actionsRow, feedbackElement);
}
} else if (actionsRow.parentElement !== location) {
location.append(actionsRow);
}
}
// Reset the shared add/edit task form back to a blank "create" state.
function resetTaskFormToCreate(menu) {
if (!menu) return;
menu.querySelector("#better-todo-new-task-title").value = "";
menu.querySelector("#better-todo-new-task-details").value = "";
menu.querySelector("#better-todo-new-task-link").value = "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
if (courseSelect) {
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
}
const now = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(now);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(now);
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "none";
const submit = menu.querySelector("#better-todo-add-task-submit");
if (submit) submit.textContent = "Create";
delete menu.dataset.editingId;
}
// Open the shared form pre-filled with a custom task for editing or deletion.
function openTaskForEdit(item) {
const location = document.getElementById("canvasrefined-todo-list");
if (!location) return;
const feedbackElement = location.querySelector(".recent_feedback");
ensureTodoTaskMenu(location, feedbackElement);
const menu = document.getElementById("better-todo-add-task-menu");
if (!menu) return;
const noteId = getCustomTaskLinkId(item);
menu.querySelector("#better-todo-new-task-title").value = item?.plannable?.title || "";
menu.querySelector("#better-todo-new-task-details").value = item?.plannable?.details || "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
const cid = item?.course_id || item?.plannable?.course_id || "";
if (courseSelect) courseSelect.value = cid ? String(cid) : "";
updateTaskCourseSelectColor(courseSelect);
const dateObj = new Date(item?.plannable_date || item?.plannable?.todo_date || Date.now());
if (!Number.isNaN(dateObj.getTime())) {
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(dateObj);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(dateObj);
}
const links = getCustomTaskLinks();
menu.querySelector("#better-todo-new-task-link").value = (noteId != null && links[String(noteId)]) || "";
menu.dataset.editingId = noteId != null ? String(noteId) : "";
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "";
menu.querySelector("#better-todo-add-task-submit").textContent = "Save";
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
menu.classList.add("canvasrefined-custom-open");
scrollTodoIntoView(menu, true);
}
async function createTodoSections(location) {
if (!location || !assignments || typeof assignments.then !== "function") return;
// Render-generation guard: capture the counter at call time and bail out of
// the async render if a newer call has started by the time our data
// resolves. Incremented only after the guards above, so a bail-out can
// never cancel an in-flight render without scheduling a replacement.
const renderGen = ++todoRenderGen;
if (!location.querySelector("#better-todo-header")) {
let header = makeElement("div", location, { id: "better-todo-header" });
header.style = "display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--bcbackground-1);padding-bottom:-2px;";
let today = new Date();
today.setHours(0,0,0,0);
const todayString = today.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" });
header.innerHTML = `
Tasks
${todayString}
`;
// Timeframe pager below the header: arrows page through the timeframe
// window (e.g. a 1-week timeframe pages this week -> week 2 -> week 3,
// skipping over everything before the window being shown). The left
// arrow is disabled on the current window. Arrow keys also work while
// hovering the pager.
const timeframeNav = makeElement("div", location, { id: "better-todo-timeframe-nav" });
timeframeNav.style = "display:none;align-items:center;justify-content:center;width:100%;margin-top:8px;user-select:none;";
timeframeNav.innerHTML = `
`;
const changeTimeframeOffset = (delta) => {
const tf = getTodoTimeframeKey();
if (tf === "all") return;
const nextOffset = Math.max(0, betterTodoTimeframeOffset + delta);
if (nextOffset === betterTodoTimeframeOffset) return;
betterTodoTimeframeOffset = nextOffset;
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
moreCompletedCount = 0;
clearTodoList();
createTodoSections(location);
};
timeframeNav.querySelector("#better-todo-timeframe-prev").addEventListener("click", () => changeTimeframeOffset(-1));
timeframeNav.querySelector("#better-todo-timeframe-next").addEventListener("click", () => changeTimeframeOffset(1));
// Keyboard arrows page the timeframe while hovering the pager (and
// only then, so normal arrow-key use elsewhere is untouched).
const timeframeKeyHandler = (e) => {
if (e.key === "ArrowRight") { e.preventDefault(); changeTimeframeOffset(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); changeTimeframeOffset(-1); }
};
timeframeNav.addEventListener("mouseenter", () => document.addEventListener("keydown", timeframeKeyHandler));
timeframeNav.addEventListener("mouseleave", () => document.removeEventListener("keydown", timeframeKeyHandler));
// placeholder for progress rings above the tab/filter control
makeElement("div", location, { id: "better-todo-progress-placeholder", style: "display:flex;justify-content:center;margin-top:8px;" });
let filterControl = makeElement("div", location, { "id": "better-todo-filter" });
filterControl.innerHTML = `
`;
setTimeout(() => updateIndicator(document.getElementById("better-todo-assignments")), 10);
document.getElementById("better-todo-announcement").addEventListener("click", (e) => {
betterTodoFilter = "announcements";
moreAnnouncementCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-assignments").addEventListener("click", (e) => {
betterTodoFilter = "tasks";
moreAssignmentCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-completed").addEventListener("click", (e) => {
betterTodoFilter = "completed";
moreCompletedCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
let mainSection = makeElement("div", location, {
id: "better-todo-main",
});
mainSection.style = "display:flex;flex-direction:column;";
}
let mainSection = location.querySelector("#better-todo-main");
// Refresh the timeframe pager's label/arrows on every render (visibility
// depends on the current tab and the todo_timeframe option).
updateTodoTimeframeNav();
assignments.then(data => {
// A newer render superseded this one — appending now would duplicate
// the whole list on top of it.
if (renderGen !== todoRenderGen) return;
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data, courseId);
// Clicking a color in the progress display filters the list to that
// one class. The filter only makes sense where multiple classes show
// (dashboard/profile), so it is cleared on course pages.
if (courseId) betterTodoProgressFilter = null;
const displayData = (betterTodoProgressFilter == null)
? scopedData
: scopedData.filter(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
return cid === String(betterTodoProgressFilter);
});
// Announcements: dismissed (marked-complete) ones are always
// hidden, and already-read ones are hidden unless the user turned
// "Hide read announcements" off. This mirrors how native Canvas
// clears announcements from the to-do once seen; without it the list
// accumulated every announcement in the lookback window (reporters
// saw 200+ "Seen" entries from a single year). Read-state filters
// alone don't fix the whole window, though: an announcement the user
// simply never opened stays "unread" forever, so year-old ones kept
// appearing under "New" and inflating the unread badge. Anything
// older than TODO_ANNOUNCEMENT_MAX_AGE_DAYS is stale regardless of
// read state and is dropped here (see that constant for rationale).
announcements = displayData.filter(item => {
if (item.plannable_type != "announcement") return false;
if (item.planner_override?.marked_complete === true) return false;
if (options.todo_hide_read !== false && item.plannable.read_state == "read") return false;
const postedAt = new Date(item.plannable_date).getTime();
if (!Number.isNaN(postedAt) && postedAt < Date.now() - TODO_ANNOUNCEMENT_MAX_AGE_DAYS * 86400000) return false;
return true;
});
// Planner items arrive oldest-first (sortAndTrimPlannerItems), so
// without this the Announcements tab showed the newest posts at the
// bottom. Display newest-first instead: sort a descending copy by
// posted date (filter() already returns a fresh array, so this never
// mutates the shared planner list).
announcements.sort((a, b) => new Date(b.plannable_date) - new Date(a.plannable_date));
// Pinned items (locally forced incomplete, e.g. a submitted assignment
// the user sent back to Tasks) always count as due; everything else is
// due only when neither submitted nor marked complete.
assignmentsDue = displayData.filter(item => isTodoTaskType(item) && ((!item.submissions?.submitted && !item.planner_override?.marked_complete) || isPinnedIncomplete(item)));
completed = displayData.filter(item => isTodoTaskType(item) && (item.submissions?.submitted || item.planner_override?.marked_complete) && !isPinnedIncomplete(item));
// The timeframe is a persisted Better Todo List sub-option set in the
// popup. Read the current value each render so popup changes apply on
// the next render. Every tab is paged through time: Tasks by due
// date, Announcements by posted date, Completed by due date. On the
// current window (offset 0) everything on/before the cutoff stays
// visible, so only paging forward narrows these two tabs.
// Pinned items ("sent back" submissions) bypass the window filter so
// an old pinned item still shows up under Tasks.
const pinnedItems = assignmentsDue.filter(item => isPinnedIncomplete(item));
assignmentsDue = applyTodoTimeframe(assignmentsDue.filter(item => !isPinnedIncomplete(item))).concat(pinnedItems);
announcements = applyTodoTimeframe(announcements);
completed = applyTodoTimeframe(completed);
// console.log("assignments", assignmentsDue);
// console.log("announcements", announcements);
// console.log("completed", completed);
if (document.getElementById("better-todo-announcement-badge")) {
document.getElementById("better-todo-announcement-badge").remove();
}
let isAnnoucementBadge = 0;
announcements.forEach(item => {
if (item.plannable.read_state == "unread") {
isAnnoucementBadge++;
return;
}
})
if (isAnnoucementBadge > 0) {
makeElement("div", document.getElementById("better-todo-announcement"), {
id: "better-todo-announcement-badge",
style: "background-color:#ff0000;width:15px;height:15px;border-radius:50%;font-size:12px;position:absolute;top:-7px;left:16px;display:flex;justify-content:center;align-items:center;", // TODO: theme compatibility
innerHTML: `${isAnnoucementBadge}`
})
}
domContainers = {};
const groupKeys = ["-1", "0", "1", "2", "3", "4", "5", "6", "7", "14", "21", "30", "Later", "New", "Seen", "Ungraded", "Graded"];
for (const key of groupKeys) {
let wrapper = makeElement("div", mainSection, {
style: "display:none;margin-top:10px;",
className: "better-todo-dueheader",
});
let label = "";
if (key == "Later") label = "Due Later";
if (key == "-1") label = "Overdue";
else if (key == "0") label = "Due Today";
else if (key == "1") label = "Due Tomorrow";
else if (key >= 2 && key < 7) label = "Due " + key + " days";
else if (key >= 7 && key < 30) label = "Due " + key/7 + " weeks";
else if (key == "30") label = "Due 1 month";
else label = "" + key + "";
makeElement("div", wrapper, {
innerHTML: "" + label + "",
style: "display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--bctext-0);"
})
let listContainer = makeElement("div", wrapper, { className: "todo-group-list" });
listContainer.style = "display:flex;flex-direction:column;gap:10px;";
domContainers[key] = { wrapper, listContainer };
}
if (betterTodoFilter == "tasks") {
populateAssignments();
}
if (betterTodoFilter == "announcements") {
populateAnnouncements();
}
if (betterTodoFilter == "completed") {
populateAssignments(true);
}
const feedbackElement = location.querySelector(".recent_feedback");
// populate progress rings placeholder (respect user toggle)
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder) {
if (progressRingsEnabled()) {
renderProgressRings(progressPlaceholder, scopedData);
} else {
progressPlaceholder.innerHTML = "";
}
}
// Only show the Add Task control on the Assignments (tasks) tab.
if (betterTodoFilter === "tasks") {
ensureTodoTaskMenu(location, feedbackElement);
} else {
const existing = location.querySelector("#better-todo-actions-row");
if (existing) existing.remove();
}
if (feedbackElement) {
if (options.todo_hide_feedback == true) {
feedbackElement.style.display = "none";
} else {
feedbackElement.style.display = "block";
}
}
const sidebar = document.getElementById("right-side-wrapper");
ensureRightSideWrapperScrollbarHidden();
sidebar.style.setProperty("scrollbar-width", "none");
sidebar.style.setProperty("-ms-overflow-style", "none");
const viewportOffset = todoViewportOffsetPx();
if (options.todo_full_height) {
sidebar.style.minHeight = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh";
} else {
sidebar.style.minHeight = "";
}
if (options.todo_separate_scrollbar) {
sidebar.style.position = "sticky";
sidebar.style.top = "0";
sidebar.style.height = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh";
sidebar.style.overflowY = "auto";
} else {
sidebar.style.position = "";
sidebar.style.top = "";
sidebar.style.height = "";
sidebar.style.overflowY = "";
// maybe invisible scrollbar?
}
}).catch(err => {
// A rejected data promise must not leave the todo list half-rendered or
// throw unhandled; the generation guard above keeps renders idempotent.
if (renderGen === todoRenderGen) console.warn("Canvas Refined - todo list render failed", err);
});
}
// Height the todo sidebar must shed to fit the viewport: the nav-toggle +
// breadcrumbs bar sits above the content column, so a full-viewport (100vh)
// sidebar forces the page to scroll by exactly the bar's height now that the
// bar is visible again (Hide Navigation Bar defaults to off). Returns 0 when
// the bar is hidden or absent (dashboard pages have no crumbs bar).
function todoViewportOffsetPx() {
if (options.hide_navbar === true) return 0;
const crumbs = document.querySelector(".ic-app-nav-toggle-and-crumbs");
const h = crumbs ? crumbs.offsetHeight : 0;
return h > 0 ? h : 0;
}
// The navbar height changes with window size/zoom, so re-apply the height
// styles on resize (without a full todo re-render).
let todoHeightResizeTimer = null;
window.addEventListener("resize", () => {
if (todoHeightResizeTimer) clearTimeout(todoHeightResizeTimer);
todoHeightResizeTimer = setTimeout(() => {
const sidebar = document.getElementById("right-side-wrapper");
if (!sidebar || !document.getElementById("better-todo-main")) return;
const viewportOffset = todoViewportOffsetPx();
if (options.todo_full_height) {
sidebar.style.minHeight = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh";
}
if (options.todo_separate_scrollbar) {
sidebar.style.height = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh";
}
}, 150);
});
function ensureRightSideWrapperScrollbarHidden() {
let style = document.getElementById("canvasrefined-hide-right-sidebar-scrollbar") || document.createElement("style");
style.id = "canvasrefined-hide-right-sidebar-scrollbar";
style.textContent = `
#right-side-wrapper {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
#right-side-wrapper::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`;
document.head.append(style);
}
function clearTodoList() {
const main = document.getElementById("better-todo-main");
const seeMoreBtn = document.getElementById("better-todo-see-more");
if (seeMoreBtn) {
seeMoreBtn.remove();
}
// Called from storage-change handlers on every page; without this guard it
// throws on pages that have no todo list mounted.
if (!main) return;
main.querySelectorAll(".todo-group-list").forEach(list => {
list.innerHTML = "";
});
document.querySelectorAll(".better-todo-dueheader").forEach(header => {
header.remove();
});
}
// "Alternate colors" (Better Todo List, light mode only): recolors the main
// todo-list icon fill to white instead of the default --bctext-0, so icons
// stay visible on lighter course-color strips. Implemented through a CSS
// variable so toggling the option or dark mode recolors existing icons live
// without a re-render.
const TODO_ALT_ICON_COLOR = "#ffffff";
let todoAltStyleEl = null;
function applyTodoAlternateColors() {
const altOn = options.todo_alternate_colors === true && options.dark_mode !== true;
const color = altOn ? TODO_ALT_ICON_COLOR : "var(--bctext-0)";
if (!todoAltStyleEl) {
todoAltStyleEl = document.createElement("style");
todoAltStyleEl.id = "crtodoaltcss";
document.documentElement.append(todoAltStyleEl);
}
todoAltStyleEl.textContent = `:root{--cr-todo-icon:${color};}`;
}
// --- Hover preview (Better Todo List: "Previews on hover") ---
// The todo list is rendered by createTodoSections -> populateAssignments /
// populateAnnouncements (the old loadBetterTodo renderer is no longer called).
// A single shared, body-level tooltip is reused across items so it is never
// clipped by the sidebar's scroll/overflow containers. It reuses the
// .canvasrefined-hover-preview class so existing light/dark styling applies.
let todoPreviewDelay = null;
let todoPreviewToken = 0;
const todoPreviewCache = new Map();
function stripHtmlPreview(html) {
if (!html) return "";
return String(html).replace(/<\/?[^>]+(>|$)/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
}
function getTodoPreviewEl() {
let el = document.getElementById("canvasrefined-todo-preview");
if (el) return el;
el = document.createElement("div");
el.id = "canvasrefined-todo-preview";
el.className = "canvasrefined-hover-preview";
el.innerHTML = '';
el.style.position = "fixed";
el.style.zIndex = "100001";
el.style.width = "300px";
el.style.maxWidth = "90vw";
el.style.maxHeight = "260px";
document.body.append(el);
return el;
}
function positionTodoPreview(el, anchor) {
const r = anchor.getBoundingClientRect();
const gap = 10;
const pw = el.offsetWidth || 300;
const ph = el.offsetHeight || 160;
let left = r.left - pw - gap;
let top = r.top;
if (left < gap) {
left = r.right + gap;
if (left + pw > window.innerWidth - gap) left = Math.max(gap, window.innerWidth - pw - gap);
}
if (top + ph > window.innerHeight - gap) top = Math.max(gap, window.innerHeight - ph - gap);
el.style.left = left + "px";
el.style.top = top + "px";
}
async function getTodoPreviewText(item) {
const type = item.plannable_type;
const id = item.plannable_id;
const key = type + ":" + id;
if (todoPreviewCache.has(key)) return todoPreviewCache.get(key);
// Custom task (planner note): the description is already on the planner
// item as item.plannable.details, so no API call is needed.
if (type === "planner_note" || (item.planner_override && item.planner_override.custom === true)) {
const raw = item.plannable && item.plannable.details ? item.plannable.details : "";
const text = stripHtmlPreview(raw) || "No details given";
todoPreviewCache.set(key, text);
return text;
}
let url = null;
let field = "description";
if (type === "assignment") {
url = `${domain}/api/v1/courses/${item.course_id}/assignments/${id}`;
} else if (type === "quiz") {
url = `${domain}/api/v1/courses/${item.course_id}/quizzes/${id}`;
} else if (type === "discussion_topic" || type === "announcement") {
url = `${domain}/api/v1/courses/${item.course_id}/discussion_topics/${id}`;
field = "message";
}
if (!url) {
const text = "No preview available";
todoPreviewCache.set(key, text);
return text;
}
try {
const data = await getData(url);
const raw = data && data[field] ? data[field] : "";
const text = stripHtmlPreview(raw) || "No details given";
todoPreviewCache.set(key, text);
return text;
} catch (e) {
return "Couldn't load preview";
}
}
function hideTodoPreview() {
todoPreviewToken++; // cancel any pending async text update
const el = document.getElementById("canvasrefined-todo-preview");
if (el) el.style.display = "none";
}
async function showTodoPreview(anchor, item) {
const token = ++todoPreviewToken;
const el = getTodoPreviewEl();
const title = el.querySelector(".canvasrefined-preview-title");
const text = el.querySelector(".canvasrefined-preview-text");
title.textContent = item.plannable && item.plannable.title ? item.plannable.title : "";
text.textContent = "Loading…";
el.style.display = "block";
positionTodoPreview(el, anchor);
const content = await getTodoPreviewText(item);
if (token !== todoPreviewToken) return; // a newer hover (or hide) superseded this one
if (el.style.display !== "block") return; // user already moved away
text.textContent = content;
positionTodoPreview(el, anchor); // reposition now that the height is known
}
function attachTodoHoverPreview(anchor, item) {
if (options.hover_preview !== true) return;
anchor.addEventListener("mouseenter", () => {
clearTimeout(todoPreviewDelay);
todoPreviewDelay = setTimeout(() => {
if (anchor.matches(":hover")) showTodoPreview(anchor, item);
}, 250);
});
anchor.addEventListener("mouseleave", () => {
clearTimeout(todoPreviewDelay);
hideTodoPreview();
});
}
// Task-type icons for the Better Todo task rows (quiz / graded discussion),
// adapted from the legacy todo renderer so quizzes and discussions get a
// recognizable icon instead of the generic assignment one. Same fill
// variable as the assignment icon so "Remove icons"/theme tweaks apply.
const TODO_QUIZ_ICON_SVG = '';
const TODO_DISCUSSION_ICON_SVG = '';
// --- Assignment scores on the Better Todo List ("Show assignment scores") ---
// Canvas's planner items API omits the actual submission score (its
// `submissions` object only carries booleans like graded/late), so points
// possible come straight from the plannable and the earned score is lazily
// fetched per course (students/submissions?student_ids[]=self) and cached for
// the session. Badges always render as "{score}/{points} pts" — an en dash
// stands in for the earned score until the fetch lands (or while the item is
// ungraded/unsubmitted), and "Excused" replaces the whole badge for excused
// submissions.
const todoScoreCache = new Map(); // `${courseId}:${plannableId}` -> {score, excused}
let todoScoreLoadToken = 0;
function formatTodoScoreNumber(n) {
return String(Math.round(n * 100) / 100);
}
// Tiny green check shown right after the score on graded assignments. Kept
// as a constant string so both the initial render and the async patch can
// insert the exact same markup.
const TODO_GRADED_BADGE_HTML = ``;
// Small inline badge shown next to the due date. "" when the item has no
// meaningful points (custom tasks, wiki pages, etc.).
function todoScoreBadgeHtml(item) {
if (item.plannable_type == "planner_note" || item.planner_override?.custom === true) return "";
const points = item.plannable?.points_possible;
if (points == null) return "";
const pts = formatTodoScoreNumber(points);
const key = `${item.course_id}:${item.plannable_id}`;
const cached = todoScoreCache.get(key);
// Graded state: the live fetched score is the strongest signal, otherwise
// fall back to the planner API's boolean — but only while the score fetch
// hasn't run yet (a cached null score means the fetch confirmed no grade).
const graded = cached ? cached.score != null : item.submissions?.graded === true;
let text;
if (cached?.excused) text = "Excused";
else text = `${cached?.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${pts} pts`;
const attrs = `data-todo-course="${item.course_id ?? ""}" data-todo-plannable="${item.plannable_id}" data-todo-points="${pts}"`;
return `${text}${graded ? TODO_GRADED_BADGE_HTML : ""}`;
}
// Fetches the current user's submission scores for every course with visible
// todo items still missing a score, then patches the badges in place (a full
// re-render would reset the "View More" expansion state). The token guards
// against overlapping loads from rapid option/tab switches: an outdated run
// stops touching the DOM, and any newer run re-collects the still-missing
// items anyway.
async function loadTodoScores(items) {
if (options.todo_show_scores !== true) return;
const byCourse = new Map();
for (const item of items) {
if (item.plannable_type == "planner_note" || item.planner_override?.custom === true) continue;
if (item.plannable?.points_possible == null) continue;
const cid = item.course_id ?? item.context_id;
if (cid == null) continue;
const key = `${cid}:${item.plannable_id}`;
if (todoScoreCache.has(key)) continue;
if (item.submissions?.excused === true) { todoScoreCache.set(key, { score: null, excused: true }); continue; }
let list = byCourse.get(String(cid));
if (!list) { list = []; byCourse.set(String(cid), list); }
list.push(item);
}
if (byCourse.size === 0) return;
const token = ++todoScoreLoadToken;
for (const [cid] of byCourse) {
try {
let url = `${domain}/api/v1/courses/${cid}/students/submissions?student_ids[]=self&per_page=100`;
// 10 pages * 100 submissions is a safety net against a malformed
// next link, same as fetchActiveCourseIds.
for (let page = 0; page < 10 && url; page++) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json", "Accept": "application/json" }
});
if (!response.ok) throw new Error(`Canvas API request failed (${response.status})`);
const data = await response.json();
if (token !== todoScoreLoadToken) return;
if (Array.isArray(data)) {
for (const s of data) {
if (s && s.assignment_id != null) {
todoScoreCache.set(`${cid}:${s.assignment_id}`, { score: s.score, excused: s.excused === true });
}
}
}
url = getNextPageUrl(response.headers.get("Link"));
}
} catch (e) {
// Leave these items uncached so a later render retries the fetch
// (e.g. teacher accounts, which the submissions endpoint rejects).
}
}
if (token !== todoScoreLoadToken) return;
// Patch every badge that now has data, on either tab.
document.querySelectorAll("#better-todo-main .better-todo-score").forEach(el => {
const cached = todoScoreCache.get(`${el.dataset.todoCourse}:${el.dataset.todoPlannable}`);
if (!cached) return;
if (cached.excused) el.textContent = "Excused";
else el.textContent = `${cached.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${el.dataset.todoPoints} pts`;
if (cached.score != null && !el.nextElementSibling?.classList.contains("better-todo-graded")) {
el.insertAdjacentHTML("afterend", TODO_GRADED_BADGE_HTML);
}
});
}
function populateAssignments(iscompleted = false) {
const today = new Date();
today.setHours(0,0,0,0);
let assignments = (iscompleted ? completed : assignmentsDue).slice();
if (iscompleted) {
assignments.sort((a, b) => {
const aIsGraded = Boolean(a.submissions?.graded);
const bIsGraded = Boolean(b.submissions?.graded);
if (aIsGraded !== bIsGraded) {
return aIsGraded - bIsGraded;
}
return new Date(b.plannable_date) - new Date(a.plannable_date);
});
} else {
// Keep a stable chronological order (overdue first) so the visible
// item budget below counts from due today onward. The planner API
// normally returns ascending order already; this makes it guaranteed.
assignments.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date));
}
let assignmentCount = 0;
const maxElements = options.num_todo_items;
assignments.forEach((item) => {
let dueGroup = -1;
if (!iscompleted) {
let dueDate = new Date(item.plannable_date);
dueDate.setHours(0,0,0,0);
const diffDays = Math.round((dueDate - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {dueGroup = -1;}
else if (diffDays <= 1) { dueGroup = diffDays.toString(); }
else if (diffDays <= 7) { dueGroup = diffDays.toString(); }
else if (diffDays <= 14) {dueGroup = 14;}
else if (diffDays <= 21) {dueGroup = 21;}
else if (diffDays <= 30) {dueGroup = 30;}
else {dueGroup = "Later"};
} else {
dueGroup = item.submissions?.graded ? "Graded" : "Ungraded";
}
let assignment
const targetContainer = domContainers[dueGroup];
// Overdue items are always shown and don't consume the visible-item
// budget: the count starts at items due today. (On the Completed tab,
// whose groups are Graded/Ungraded, every item still counts.)
const isOverdue = !iscompleted && dueGroup === -1;
if (!isOverdue) assignmentCount++;
let isHidden = !isOverdue && assignmentCount > maxElements;
if (targetContainer) {
if (!isHidden) {
targetContainer.wrapper.style.display = "block";
targetContainer.wrapper.setAttribute("data-has-visible", "true");
}
else {
if (!targetContainer.wrapper.hasAttribute("data-has-visible")) {
targetContainer.wrapper.classList.add(
"better-todo-hidden-wrapper",
);
}
}
// targetContainer.wrapper.style.display = "block";
assignment = makeElement("div", targetContainer.listContainer, {
class: "better-todo-assignment",
});
if (isHidden) {
assignment.style.display = "none";
assignment.classList.add("better-todo-hidden-assignment");
}
}
const courseColor =
options.custom_cards_3?.[String(item.course_id)]?.color ??
options.custom_cards_3?.[item.course_id]?.color ??
options.custom_cards_3?.[item.plannable.course_id]?.color ??
"#cccccc";
// "Ignore card colors" (Better Todo List): when on, the class name is
// rendered black in light mode or the theme text color in dark mode
// instead of the course's card color.
const classNameColor = options.todo_ignore_card_colors
? (options.dark_mode === true ? "var(--bctext-0)" : "#000000")
: courseColor;
// "Remove icons" (Better Todo List): when on, the task-type icon is
// omitted from the colored strip on the left of each task.
const removeIcons = options.todo_remove_icons === true;
const isCustomTask = item.plannable_type == "planner_note" || item.planner_override?.custom === true;
// True when Canvas has a real submission for this item: the Completed
// list shows it regardless of the checkmark state, so the toggle is
// disabled for it (see the checkmark listener below).
const wasSubmitted = item.submissions?.submitted === true;
const taskHref = isCustomTask ? customTaskHref(item) : (domain + item.html_url);
const editButtonSvg = isCustomTask
? ``
: "";
const iconSize = isCustomTask ? 26 : 20;
// Optical centering inside the colored strip: the assignment icon's
// glyph hugs its viewBox's left edge, so it gets a 5px nudge right.
// The quiz glyph's visual weight already sits right of center (its
// thin "motion lines" occupy the left edge), so it needs less of a
// nudge — otherwise it reads as off-center next to the others.
const iconLeftOffset = isCustomTask ? 2 : item.plannable_type == "quiz" ? 2 : 5;
const scoreBadge = options.todo_show_scores === true ? todoScoreBadgeHtml(item) : "";
const taskIcon = removeIcons ? "" : isCustomTask
? ``
: (item.plannable_type == "quiz" ? TODO_QUIZ_ICON_SVG : item.plannable_type == "discussion_topic" ? TODO_DISCUSSION_ICON_SVG : ``);
assignment.style.overflowX = "hidden";
assignment.innerHTML = `
`;
// "Mark as seen": sets the same planner override the task checkmark
// uses, which also hides the announcement from the list (see the
// announcements filter in createTodoSections). Two-way, so with "Hide
// read announcements" off a dismissed one can be un-seen again.
announcement.querySelector(".better-todo-announcement-checkmark").addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
markAs(item, announcement.firstElementChild, !(item.planner_override?.marked_complete === true));
});
attachTodoHoverPreview(announcement, item);
});
}
function createConfettiBurst(targetElement, opts = {}) {
try {
if (options.todo_confetti === false) return;
const count = opts.count || 48;
const colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f'];
const rect = targetElement.getBoundingClientRect();
const container = document.createElement('div');
container.className = 'canvasrefined-confetti-container';
container.style.position = 'fixed';
container.style.left = '0';
container.style.top = '0';
container.style.pointerEvents = 'none';
container.style.overflow = 'visible';
container.style.zIndex = '2147483647';
document.body.appendChild(container);
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.35;
const particles = [];
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'canvasrefined-confetti';
const w = 4 + Math.floor(Math.random() * 7); // smaller pieces
const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8)));
el.style.position = 'absolute';
el.style.width = w + 'px';
el.style.height = h + 'px';
el.style.background = colors[Math.floor(Math.random() * colors.length)];
el.style.left = (originX - w / 2) + 'px';
el.style.top = (originY - h / 2) + 'px';
el.style.opacity = '1';
el.style.borderRadius = Math.random() > 0.75 ? '50%' : '2px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.18)';
el.style.transformOrigin = 'center center';
el.style.willChange = 'transform, opacity';
container.appendChild(el);
const duration = 850 + Math.floor(Math.random() * 500);
const delay = Math.floor(Math.random() * 90);
const spread = opts.spread || 110;
const horizontalBias = (Math.random() - 0.5) * 2;
// Arc stays lower and wider than the old cone-shaped burst.
const endX = originX + horizontalBias * (spread * (0.7 + Math.random() * 0.6));
const endY = originY - (16 + Math.random() * 34);
const ctrlX = originX + horizontalBias * (spread * 0.25) + (Math.random() - 0.5) * 14;
const ctrlY = originY - (30 + Math.random() * 55);
particles.push({
el,
delay,
duration,
originX,
originY,
ctrlX,
ctrlY,
endX,
endY,
rotate: (Math.random() * 260) - 130,
scale: 0.8 + Math.random() * 0.5,
});
}
const startTime = performance.now();
let rafId = null;
const animate = now => {
let active = false;
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
const elapsed = now - startTime - particle.delay;
if (elapsed < 0) {
active = true;
continue;
}
const progress = Math.min(1, elapsed / particle.duration);
const eased = 1 - Math.pow(1 - progress, 3);
const x = (1 - eased) * (1 - eased) * particle.originX + 2 * (1 - eased) * eased * particle.ctrlX + eased * eased * particle.endX;
const y = (1 - eased) * (1 - eased) * particle.originY + 2 * (1 - eased) * eased * particle.ctrlY + eased * eased * particle.endY;
particle.el.style.transform = `translate(${Math.round(x - particle.originX)}px, ${Math.round(y - particle.originY)}px) rotate(${particle.rotate * eased}deg) scale(${particle.scale * (1 - eased * 0.15)})`;
particle.el.style.opacity = String(1 - progress);
if (progress < 1) {
active = true;
} else {
particle.el.remove();
particles.splice(i, 1);
}
}
if (active) {
rafId = requestAnimationFrame(animate);
} else {
try { container.remove(); } catch (e) { /* ignore */ }
if (rafId) cancelAnimationFrame(rafId);
}
};
rafId = requestAnimationFrame(animate);
// cleanup container after animations
setTimeout(() => {
try { container.remove(); } catch (e) { /* ignore */ }
}, 2400);
} catch (e) {
console.error('confetti error', e);
}
}
// --- Local "incomplete" pins -----------------------------------------------
// Canvas records real submissions server-side and offers no API to un-submit,
// so an assignment the user actually handed in can never leave the Completed
// list via the planner override. For those items the checkmark instead sets a
// local pin that forces the item back into the Tasks list. The pin lives in
// chrome.storage.sync and clears when the item is checked off again.
function todoPinKey(item) {
return item.plannable_type + "_" + item.plannable_id;
}
function isPinnedIncomplete(item) {
return options.todo_force_incomplete?.[todoPinKey(item)] === true;
}
function setPinnedIncomplete(item, pinned) {
const pins = Object.assign({}, options.todo_force_incomplete || {});
if (pinned) pins[todoPinKey(item)] = true;
else delete pins[todoPinKey(item)];
options.todo_force_incomplete = pins;
chrome.storage.sync.set({ todo_force_incomplete: pins });
}
function markAs(item, element, makeComplete) {
const csrfToken = CSRFtoken();
const completeState = makeComplete;
// Checking an item off clears any local incomplete pin; unchecking a
// genuinely submitted item sets one, since the submission itself can't
// be undone server-side.
if (completeState) {
if (isPinnedIncomplete(item)) setPinnedIncomplete(item, false);
} else if (item.submissions?.submitted) {
setPinnedIncomplete(item, true);
}
// --- Optimistic UI ---
// Canvas's /planner/overrides endpoint occasionally returns 400 (Bad
// Request) for both custom tasks and normal tasks, even though the action
// is actually applied server-side shortly after. When that happens the
// item would neither visually mark nor animate, and would later appear
// "secretly complete" on reload. To avoid that confusing UX, we update the
// UI as if the request succeeded immediately, and fire the actual API
// call in the background for persistence.
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = completeState;
element.style.transform = "translate(100%)";
element.style.opacity = "0";
// fire confetti only when marking complete (not when unmarking)
if (completeState) {
try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); }
}
// update progress rings immediately so they animate while the item slides/fades
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data.map(d => Object.assign({}, d)), courseId);
// reflect the updated state for this item in the snapshot
for (let i = 0; i < scopedData.length; i++) {
if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) {
scopedData[i].planner_override = scopedData[i].planner_override || {};
scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete;
break;
}
}
renderProgressRings(progressPlaceholder, scopedData);
});
}
setTimeout(() => {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}, 400);
// --- Persistence (background, best-effort) ---
// Toggle the planner override server-side. The override id returned by a
// successful POST/PUT is stored on the in-memory item so subsequent
// toggles PUT (update) instead of POSTing a duplicate. If a POST still
// collides with an existing override (400 "already exists"), we look up
// the real id and retry with a PUT. Only if all of that fails is the
// response treated as a soft success: the UI already reflects the
// intended state, so we just log it.
const sendOverride = (override) => fetch(domain + "/api/v1/planner/overrides" + (override && override.id ? "/" + override.id : ""), {
method: override && override.id ? "PUT" : "POST",
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
id: override && override.id ? override.id : null,
marked_complete: completeState,
plannable_id: item.plannable_id,
plannable_type: item.plannable_type
})
});
sendOverride(item.planner_override)
.then(async resp => {
if (resp.ok) {
// Capture the override id from the response. A POST creates the
// override server-side with an id we don't know yet; without
// storing it, every later toggle on this item would POST again and
// collide with the existing override (400 "already exists"), so
// unchecking would never actually persist.
try {
const saved = await resp.json();
if (saved && saved.id) {
item.planner_override = item.planner_override || {};
item.planner_override.id = saved.id;
}
} catch (e) { /* no JSON body; id stays unknown */ }
console.log("marked as complete");
return;
}
// A POST can return 400 because an override already exists server-side
// under an id we never learned (e.g. it was created earlier without the
// response being captured). Look up the user's overrides, find the one
// for this item, and retry with a PUT so the toggle actually persists.
if (!(item.planner_override && item.planner_override.id)) {
try {
const listResp = await fetch(domain + "/api/v1/planner/overrides", {
headers: { "accept": "application/json" }
});
if (listResp.ok) {
const overrides = await listResp.json();
const match = (Array.isArray(overrides) ? overrides : []).find(o =>
o &&
String(o.plannable_id) === String(item.plannable_id) &&
String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase());
if (match && match.id) {
item.planner_override = item.planner_override || {};
item.planner_override.id = match.id;
const retry = await sendOverride(item.planner_override);
if (!retry.ok) {
console.warn("planner override PUT retry returned", retry.status);
}
return;
}
}
} catch (e) {
console.error("error recovering planner override id", e);
}
}
// Still non-OK with nothing to recover: logged but not surfaced, the
// UI already reflects the intended state.
console.warn("planner override request returned", resp.status, "— UI already updated optimistically");
})
.catch(err => console.error("error marking as complete", err));
}
function createTodoViewMore(location, type) {
let viewMoreButton = makeElement("button", location, { "className": "canvasrefined-custom-btn canvasrefined-viewmore-btn", "textContent": "View More" });
//viewMoreButton.classList.add("canvasrefined-viewmore-btn");
const showMoreCount = 3;
viewMoreButton.addEventListener("click", function (e) {
if (type === "announcement") {
moreAnnouncementCount += showMoreCount;
} else {
moreAssignmentCount += showMoreCount;
}
loadBetterTodo();
});
}
// better todo init
function setupBetterTodo() {
// Better Todo list is removed from quizzes (it interferes with the quiz page).
if (isQuizPage()) return;
if (options.better_todo !== true || isGradesPage()) return;
if (document.querySelector('#canvasrefined-todo-list')) return;
// The dashboard MutationObserver can fire before getApiData() has assigned
// the `assignments` promise. Creating the sidebar now would leave it
// permanently empty: createTodoSections would throw on `assignments.then`
// after the shell was built, and the existing-element guard above prevents
// any retry. Bail instead — Canvas keeps mutating the DOM during load, so
// checkDashboardReady calls us again once data is ready.
if (!assignments || typeof assignments.then !== "function") return;
let list = document.querySelector("#right-side");
if (!list) return;
//if (!list || list.childElementCount === 0 || list.children[0].id === "canvasrefined-todo-list") return;
try {
/* save the feedback to append it later */
const feedback = list.querySelector(".events_list.recent_feedback");
list.textContent = "";
list = makeElement("div", list, { "className": "canvasrefined-todosidebar","id": "canvasrefined-todo-list"});
createTodoSections(list);
if (feedback) list.append(feedback);
} catch (e) {
logError(e);
}
}
function getSidebarScale() {
const rawScale = parseInt(options.sidebar_scale || 100);
if (isNaN(rawScale)) return 1;
return Math.max(0.7, Math.min(1.5, rawScale / 100));
}
function applySidebarScaleStyles(sidebarList) {
const scale = getSidebarScale();
sidebarList.style.setProperty("--bc-sidebar-icon-size", `${Math.round(20 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-height", `${Math.round(30 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-gap", `${Math.round(8 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-label-size", `${Math.round(14 * scale)}px`);
}
// Re-apply the tinted course-content panel when the background opacity slider
// changes. Only the better sidebar (course mode) gives .ic-Layout-contentMain a
// tinted panel in the first place (see setupBetterSidebar). Re-applies both the
// opacity and blur sliders so changing either updates the panel live.
function applyBetterSidebarContentPanel() {
if (!options.better_sidebar) return;
if (getSidebarLayoutMode() !== "course") return;
const contentMain = document.querySelector(".ic-Layout-contentMain");
if (!contentMain) return;
const bgOpacity = Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)));
const bgBlur = Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)));
contentMain.style.setProperty("background", `color-mix(in srgb, var(--bcbackground-0) ${bgOpacity}%, transparent)`, "important");
contentMain.style.setProperty("backdrop-filter", `blur(${bgBlur}px)`, "important");
contentMain.style.setProperty("-webkit-backdrop-filter", `blur(${bgBlur}px)`, "important");
}
async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
if (!options.better_sidebar) return;
if (document.querySelector('#better-sidebar-container')) return;
let wrapper = document.querySelector("#wrapper");
if (!wrapper || betterSidebarLoading) return;
betterSidebarLoading = true;
try {
const layoutMode = mode === "course" || mode === "dash" ? mode : getSidebarLayoutMode();
let expanded = await getSidebarExpandedState(layoutMode);
const outerWrapper = document.getElementById("main");
outerWrapper?.style.setProperty("display", "flex", "important");
// document.getElementById("not_right_side").style.setProperty("display", "none", "important");
const leftSide = document.getElementById("left-side");
leftSide?.style.setProperty("opacity", "1");
leftSide?.style.setProperty("position", "static");
const mainWrapper = document.querySelector(".ic-Layout-contentWrapper");
if (!mainWrapper) return;
applyBetterSidebarLayoutFix();
mainWrapper.style.display = "flex";
mainWrapper.style.alignItems = "stretch";
mainWrapper.style.minWidth = "0";
const contentMain = document.querySelector(".ic-Layout-contentMain");
contentMain?.style.setProperty("flex", "1 1 auto");
contentMain?.style.setProperty("min-width", "0");
const notRightSide = document.getElementById("not_right_side");
if (notRightSide && isAccountsPage()) {
notRightSide.style.setProperty("width", "100%");
notRightSide.style.setProperty("max-width", "100%");
notRightSide.style.setProperty("min-width", "0");
}
if (layoutMode === "course" && leftSide) {
const rightSideWrapper = document.getElementById("right-side-wrapper");
const sectionTabs = document.getElementById("section-tabs");
leftSide.style.setProperty("padding-top", "0", "important");
leftSide.style.setProperty("padding-left", "0", "important");
if (sectionTabs) {
if (getCurrentCourseId() !== null || isProfilePage()) {
sectionTabs.style.setProperty("padding-top", "40px", "important");
} else {
sectionTabs.style.removeProperty("padding-top");
}
}
leftSide.style.flex = "0 0 250px";
leftSide.style.width = "250px";
leftSide.style.maxWidth = "250px";
if (notRightSide) {
notRightSide.style.display = "flex";
notRightSide.style.flex = "1 1 auto";
notRightSide.style.minWidth = "0";
}
if (rightSideWrapper) {
rightSideWrapper.style.flex = "0 0 280px";
rightSideWrapper.style.width = "280px";
rightSideWrapper.style.maxWidth = "280px";
}
contentMain?.style.setProperty("margin", "26px 38px 38px", "important");
contentMain?.style.setProperty("padding", "10px", "important");
contentMain?.style.setProperty("border-radius", "10px", "important");
contentMain?.style.setProperty("background", `color-mix(in srgb, var(--bcbackground-0) ${Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)))}%, transparent)`, "important");
contentMain?.style.setProperty("backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
contentMain?.style.setProperty("-webkit-backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
}
// The rail must always render leftmost. Course-layout pages already
// prepend it into #left-side; dash-layout pages that still have a
// native left nav (accounts, groups, etc.) must too — otherwise the
// native #left-side column (made position:static above) flows before
// #not_right_side and shows up to the LEFT of the Better Sidebar,
// looking like a competing sidebar once the custom background tints
// it. Prepending keeps the order: [Better Sidebar rail][native nav].
const sidebarParent = leftSide ? leftSide : mainWrapper;
if (leftSide) {
leftSide.style.display = "flex";
leftSide.style.flexDirection = "row";
leftSide.style.alignItems = "stretch";
leftSide.style.minWidth = "0";
leftSide.style.gap = "0";
}
// Only hide the nav-toggle + breadcrumbs bar when the Better Sidebar
// "Hide Navigation Bar" sub-option is on — this used to be hidden
// unconditionally. (The global hide lives in applyAestheticChanges;
// live toggling is handled in applyOptionsChanges.)
if (options.hide_navbar === true) {
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.setProperty("display", "none");
}
if (layoutMode == "dash") {
document.getElementById("header")?.style.setProperty("display", "none");
}
else if (layoutMode == "course") {
document.getElementById("header")?.style.setProperty("display", "none");
}
let sidebarList = makeElement("div", sidebarParent, { id: "better-sidebar-container",
style: `display:flex;flex-direction:column;width:50px;justify-content:center;align-items:center;box-sizing:border-box;position:relative;background-color:var(--bcsidebar);height:100vh;position:sticky;top:0;left:0;`
}, true);
let sidebarContent = makeElement("div", sidebarList, {
style: "display:flex;flex-direction:column;gap:20px;width:100%;flex:1;justify-content:flex-start;align-items:center;margin:40px;"
});
applySidebarScaleStyles(sidebarList);
let expander = makeElement("div", sidebarList, {
className: "better-sidebar-expander",
style: "display:flex;flex-direction:column;gap:0px;margin-top:auto;width:100%;justify-content:center;align-items:center;cursor:pointer;",
});
expander.innerHTML = `
`
sidebarList.dataset.expanded = expanded ? "true" : "false";
updateSidebar(expanded, sidebarList, expander);
setSidebarExpandedState(layoutMode, expanded);
requestAnimationFrame(() => {
populateSidebarFromNav(sidebarContent);
updateSidebar(expanded, sidebarList, expander);
watchSidebarBadges();
});
expander.addEventListener("click", () => {
expanded = !expanded;
sidebarList.dataset.expanded = expanded ? "true" : "false";
setSidebarExpandedState(layoutMode, expanded);
updateSidebar(expanded, sidebarList, expander);
})
} catch (e) {
logError(e);
} finally {
betterSidebarLoading = false;
}
}
function createSidebarButton(text, url, parent, icon) {
let button = makeElement("a", parent, {
style: "width:40%;height:var(--bc-sidebar-btn-height,30px);cursor:pointer;text-align:center;text-decoration:none;display:inline-flex;justify-content:center;align-items:center;gap:var(--bc-sidebar-btn-gap,8px);color:var(--bcsidebar-text) !important;font-weight:bold;position:relative;",
className: "canvasrefined-custom-btn better-sidebar-btn",
href: url,
});
button.innerHTML = `${icon ? `${icon}${text}` : `${text}`}`;
return button;
}
function getNavBadgeCount(item) {
const badge = item.querySelector(".menu-item__badge");
if (!badge) return 0;
const badgeText = badge.querySelector('[aria-hidden="true"]')?.textContent?.trim() || badge.textContent?.trim() || "";
const count = parseInt(badgeText, 10);
return Number.isFinite(count) && count > 0 ? count : 0;
}
function addSidebarButtonBadge(button, count) {
if (!button) return;
// Always clear any existing badge first so a drop to 0 unread removes it.
button.querySelector(".better-sidebar-badge")?.remove();
if (!count) return;
makeElement("div", button, {
className: "better-sidebar-badge",
style: "position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background-color:#ff0000;color:white;font-size:11px;line-height:16px;display:flex;justify-content:center;align-items:center;box-sizing:border-box;pointer-events:none;",
textContent: String(count),
});
}
// Re-read the global nav badge for each better-sidebar button and update its dot.
// Canvas loads the unread counts (Inbox/announcements) asynchronously after the
// page renders, so the dot captured at build time is often missing or stale.
function syncSidebarBadges() {
document.querySelectorAll(".better-sidebar-btn").forEach(button => {
const navId = button.dataset.navItemId;
if (!navId) return;
const navItem = document.getElementById(navId);
if (!navItem) return;
addSidebarButtonBadge(button, getNavBadgeCount(navItem));
});
}
function scheduleSidebarBadgeSync() {
if (sidebarBadgeSyncTimer) clearTimeout(sidebarBadgeSyncTimer);
sidebarBadgeSyncTimer = setTimeout(() => {
sidebarBadgeSyncTimer = null;
syncSidebarBadges();
}, 100);
}
// Watch the global nav for badge changes (late load, new mail, read/unread)
// and keep the better-sidebar dots in sync.
function watchSidebarBadges() {
const navMenu = document.getElementById("menu");
if (!navMenu) {
if (sidebarBadgeWatchRetries++ < 20) setTimeout(watchSidebarBadges, 500);
return;
}
sidebarBadgeWatchRetries = 0;
if (sidebarBadgeObserver) sidebarBadgeObserver.disconnect();
sidebarBadgeObserver = new MutationObserver(scheduleSidebarBadgeSync);
sidebarBadgeObserver.observe(navMenu, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
scheduleSidebarBadgeSync();
}
function populateSidebarFromNav(sidebarContent) {
const excludeIds = ["global_nav_help_link", "global_nav_history_link"];
const customIcons = {
"global_nav_profile_link": ``,
"global_nav_dashboard_link": ``,
"global_nav_conversations_link": ``,
"global_nav_calendar_link": ``,
"global_nav_courses_link": ``,
"global_nav_groups_link": ``,
"globalNavExternalTool-69": ``,
};
const navMenu = document.getElementById("menu");
let hasDashboardButton = false;
// Keep the global-search trigger last in the sidebar. The search button's
// placement pass and this populate pass run on independent rAF callbacks,
// so either can execute first. If the search button was appended before
// the nav buttons exist, slot the nav buttons in ahead of it so Search
// always stays at the bottom of the sidebar.
const searchBtn = sidebarContent.querySelector("#canvasrefined-gs-sidebar-btn");
const insertNavButton = (text, href, icon) => {
const button = createSidebarButton(text, href, sidebarContent, icon);
if (searchBtn && searchBtn.parentNode === sidebarContent) {
sidebarContent.insertBefore(button, searchBtn);
}
return button;
};
if (navMenu) {
const menuItems = navMenu.querySelectorAll("a[id^='global_nav'], .globalNavExternalTool a");
menuItems.forEach(item => {
const itemId = item.id;
if (excludeIds.includes(itemId)) return;
const href = item.getAttribute("href");
let textEl = item.querySelector(".menu-item__text");
let text = textEl?.textContent?.trim();
// If text not found, try other sources
if (!text) {
text = item.getAttribute("aria-label")?.trim() ||
item.getAttribute("title")?.trim() ||
item.textContent?.trim();
}
if (!text || !href) return;
let icon = customIcons[itemId] || "";
if (!icon) {
const svg = item.querySelector("svg");
if (svg) {
icon = svg.outerHTML;
// Detect and scale down large viewBox SVGs
const viewBoxMatch = icon.match(/viewBox="([^"]+)"/);
if (viewBoxMatch) {
const [, viewBox] = viewBoxMatch;
const parts = viewBox.split(/\s+/);
const width = parseFloat(parts[2]);
const height = parseFloat(parts[3]);
// If viewBox is large, add fixed size to scale it down
if (width > 32 || height > 32) {
// Check if svg already has a style attribute
if (icon.includes('style="')) {
// Append to existing style
icon = icon.replace(/style="([^"]*)"/, `style="$1 width:20px;height:20px;flex-shrink:0;fill:var(--bcsidebar-text);stroke:var(--bcsidebar-text);"`);
} else {
// Add new style attribute
icon = icon.replace("/g, ">").replace(/"/g, """).replace(/'/g, "'");
const sanitizeUrl = (u) => {
const v = String(u == null ? "" : u).trim();
if (!v) return "";
if (/^(https?:|mailto:|ftp:|tel:)/i.test(v)) return v;
if (/^(javascript:|vbscript:|file:|data:)/i.test(v)) return "#";
if (/^[#/?]/.test(v)) return v;
if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return "#";
return v;
};
let text = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const out = [];
const lines = text.split("\n");
let i = 0;
let taskSeq = 0; // ordinal of rendered task items, stable across code-block stashing
const inline = (t) => {
let h = escapeHtml(t);
h = h.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, alt, url, title) =>
``);
h = h.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) =>
`${txt}`);
h = h.replace(/`([^`\n]+)`/g, (m, c) => `${c}`);
h = h.replace(/\*\*([^*]+?)\*\*/g, "$1");
h = h.replace(/~~([^~]+?)~~/g, "$1");
h = h.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1$2");
return h;
};
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; out.push(`${inline(h[2])}`); i++; continue; }
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push(""); i++; continue; }
if (/^>\s?/.test(line)) {
const q = []; while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(inline(lines[i].replace(/^>\s?/, ""))); i++; }
out.push(`
${q.join(" ")}
`); continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (!m) break; const tk = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (tk) { items.push(`
${inline(tk[2])}
`); } else { items.push(`
${inline(m[1])}
`); } i++; } out.push(`
${items.join("")}
`); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*\d+\.\s+(.*)$/); if (!m) break; items.push(`
${inline(m[1])}
`); i++; } out.push(`${items.join("")}`); continue;
}
const para = [line]; i++; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|>)/.test(lines[i])) { para.push(lines[i]); i++; }
out.push(`
${para.map(inline).join(" ")}
`);
}
return out.join("\n");
}
function renderDashboardNotesPreview(preview, text) {
if (!preview) return;
// Skip identical re-renders to avoid a self-sustaining observer loop.
if (preview._crLastText === text) return;
preview._crLastText = text;
const renderer = (typeof window.renderMarkdown === "function") ? window.renderMarkdown : crRenderMarkdownFallback;
preview.innerHTML = renderer(text);
}
/* Insert/wrap Markdown formatting at the selection and fire an input event. */
function notesApplyFormat(editor, action) {
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
const fire = () => {
editor.dispatchEvent(new Event("input", { bubbles: true }));
editor.focus();
};
const wrap = (before, after, placeholder) => {
const had = end > start;
const sel = had ? value.slice(start, end) : (placeholder || "");
editor.setRangeText(before + sel + after, start, end, "end");
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + sel.length;
fire();
};
// Range covering every line touched by the selection.
const lineBlock = () => {
const ls = start === 0 ? 0 : value.lastIndexOf("\n", start - 1) + 1;
let le = value.indexOf("\n", end);
if (le === -1) le = value.length;
return { ls, le, block: value.slice(ls, le) };
};
const togglePrefix = (prefix) => {
const { ls, le, block } = lineBlock();
const lines = block.split("\n");
const allHave = lines.every((l) => l.startsWith(prefix));
const newBlock = lines.map((l) => allHave ? l.slice(prefix.length) : prefix + l).join("\n");
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
};
switch (action) {
case "bold": wrap("**", "**", "bold"); break;
case "italic": wrap("*", "*", "italic"); break;
case "strike": wrap("~~", "~~", "strikethrough"); break;
case "code": wrap("`", "`", "code"); break;
case "h1": togglePrefix("# "); break;
case "h2": togglePrefix("## "); break;
case "list": togglePrefix("- "); break;
case "numbered": togglePrefix("1. "); break;
case "quote": togglePrefix("> "); break;
case "task": {
const { ls, le, block } = lineBlock();
const marker = block.match(/^-\s*\[([ xX])\]\s+/);
const bullet = block.match(/^[-*+]\s+/);
let newBlock;
if (marker) {
newBlock = block.slice(marker[0].length);
} else if (bullet) {
newBlock = "- [ ] " + block.slice(bullet[0].length);
} else {
newBlock = "- [ ] " + block;
}
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
break;
}
case "link": {
const had = end > start;
const sel = had ? value.slice(start, end) : "text";
editor.setRangeText("[" + sel + "](url)", start, end, "end");
const urlStart = start + 1 + sel.length + 2; // after "]("
editor.selectionStart = urlStart;
editor.selectionEnd = urlStart + 3; // select "url"
fire();
break;
}
case "hr": {
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "---\n", start, start, "end");
fire();
break;
}
case "codeblock": {
const had = end > start;
const sel = had ? value.slice(start, end) : "code";
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "```\n" + sel + "\n```", start, end, "end");
fire();
break;
}
}
}
/* Toggle the Nth rendered Markdown task checkbox and re-render, using a task
ordinal that skips fenced code blocks. */
function toggleDashboardNoteTask(editor, taskIndex) {
if (!editor) return;
const value = editor.value || "";
const lines = value.split("\n");
let inFence = false;
let count = 0;
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^(\s*[-*+]\s+)\[([ xX])\](\s+)/);
if (!m) continue;
if (count === taskIndex) {
const newMark = m[2] === " " ? "x" : " ";
lines[idx] = m[1] + "[" + newMark + "]" + m[3] + line.slice(m[0].length);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const notes = editor.closest(".canvasrefined-dashboard-notes");
const rendered = notes ? notes.querySelector(".canvasrefined-notes-rendered") : null;
if (rendered) renderDashboardNotesPreview(rendered, editor.value);
return;
}
count++;
}
}
const DASHBOARD_NOTES_HTML = `
`;
function wireDashboardNotes(notes) {
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
editor.value = options.dashboard_notes_text || "";
renderDashboardNotesPreview(rendered, editor.value);
const enterEdit = () => {
if (notes.classList.contains("is-editing")) return;
notes.classList.add("is-editing");
editor.focus();
const len = editor.value.length;
editor.setSelectionRange(len, len);
};
const exitEdit = () => {
notes.classList.remove("is-editing");
renderDashboardNotesPreview(rendered, editor.value);
};
rendered.addEventListener("click", (e) => {
// Clicking a checkbox toggles the task; clicking the text edits.
const t = e.target;
if (t && t.nodeType === 1 && t.tagName === "INPUT" && t.type === "checkbox" && t.closest && t.closest("li.cr-task")) {
e.stopPropagation();
const li = t.closest("li.cr-task");
const taskIndex = parseInt(li.dataset.crTask, 10);
if (!Number.isNaN(taskIndex)) toggleDashboardNoteTask(editor, taskIndex);
return;
}
enterEdit();
});
rendered.addEventListener("focus", enterEdit);
editor.addEventListener("blur", exitEdit);
editor.addEventListener("input", function () {
options.dashboard_notes_text = this.value;
delayDashboardNotesStorage(this.value);
});
// preventDefault on the toolbar keeps focus in the editor across misclicks.
const toolbar = notes.querySelector(".canvasrefined-notes-toolbar");
if (toolbar) toolbar.addEventListener("mousedown", e => e.preventDefault());
notes.querySelectorAll(".cr-fmt").forEach(btn => {
btn.addEventListener("click", () => {
if (!notes.classList.contains("is-editing")) enterEdit();
notesApplyFormat(editor, btn.dataset.action);
});
});
editor.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.preventDefault(); editor.blur(); return; } // Esc: render
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "b") { e.preventDefault(); notesApplyFormat(editor, "bold"); }
else if (k === "i") { e.preventDefault(); notesApplyFormat(editor, "italic"); }
else if (k === "enter") { e.preventDefault(); editor.blur(); } // Ctrl/Cmd+Enter: render
});
}
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
if (!container) return;
let notes = document.querySelector('.canvasrefined-dashboard-notes');
// Rebuild older (split edit/preview) markup into the new single-surface layout.
if (notes && !notes.querySelector(".canvasrefined-notes-surface")) {
notes.remove();
notes = null;
}
if (!notes) {
notes = document.createElement("div");
notes.classList.add("canvasrefined-dashboard-notes");
notes.innerHTML = DASHBOARD_NOTES_HTML;
// Mount as a full-width sibling above the card grid. Prepending inside the
// DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken).
const parent = container.parentNode;
if (parent) parent.insertBefore(notes, container);
else container.prepend(notes);
wireDashboardNotes(notes);
} else {
notes.style.display = "";
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
// Only sync and render in view mode; the textarea is the source of truth while editing.
if (!notes.classList.contains("is-editing")) {
if (editor && editor.value !== (options.dashboard_notes_text || "")) {
editor.value = options.dashboard_notes_text || "";
}
renderDashboardNotesPreview(rendered, editor ? editor.value : "");
}
}
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) notes.style.display = "none";
}
}
/*
Custom font
*/
// "Keep original assignment font": Canvas sets its font (Lato) on
// via author CSS, and the custom-font rule's `*` selector overrides it. To
// keep
original we (a) carve p + its descendants out of the custom rule
// and (b) pin them back to the font body had BEFORE the custom rule landed —
// plain `inherit` wouldn't work because p's ancestors still carry the custom
// font via `*`. The pre-custom font is captured once from body's computed
// style (before our
`;
const toggleBtn = panel.querySelector("#canvasrefined-ga-toggle");
const imagineBtn = panel.querySelector("#canvasrefined-ga-imagine");
const fitYCheckbox = panel.querySelector("#canvasrefined-ga-fity");
const applyOpenState = () => applyGradeAnalyticsOpenState(panel);
toggleBtn.addEventListener("click", () => {
gaOpen = !gaOpen;
setGradeAnalyticsOpenState(gaOpen);
applyOpenState();
if (gaOpen && gaData) renderGradeAnalytics();
});
// "Imagine-If mode" button on the right side of the panel header. The
// toggle is per-page-view only (always off on load); the active style
// highlights the button while the mode is on.
imagineBtn.addEventListener("click", () => {
gaImagineIf = !gaImagineIf;
applyGradeAnalyticsImagineState(panel);
if (gaImagineIf) gaEnterImagineIf();
else gaExitImagineIf();
});
// "Fit Y axis" scales the line chart's Y axis to the data instead of a
// fixed 0-100; the choice is remembered across pages via chrome.storage.
fitYCheckbox.checked = gaFitY;
fitYCheckbox.addEventListener("change", () => {
gaFitY = fitYCheckbox.checked;
setGradeAnalyticsFitY(gaFitY);
if (gaData) {
gaDrawLine(panel.querySelector("#canvasrefined-ga-line"), panel.querySelector("#canvasrefined-ga-line-tip"));
}
});
// Tab switching between the charts overview and the final calculator.
panel.querySelectorAll("[data-ga-tab]").forEach(btn => {
btn.addEventListener("click", () => gaSetTab(btn.dataset.gaTab));
});
// Final-grade calculator inputs persist per course; every change re-saves
// and recomputes the result against the live current grade.
const calcWeight = panel.querySelector("#canvasrefined-ga-calc-weight");
const calcTarget = panel.querySelector("#canvasrefined-ga-calc-target");
const calcShow = panel.querySelector("#canvasrefined-ga-calc-show");
const onCalcInput = () => {
gaCalc = gaCalc || { weight: null, target: null, show: false };
const w = parseFloat(calcWeight.value);
const t = parseFloat(calcTarget.value);
gaCalc.weight = isFinite(w) ? Math.min(100, Math.max(0, w)) : null;
gaCalc.target = isFinite(t) ? Math.max(0, t) : null;
gaCalc.show = calcShow.checked;
saveGaCalcSettings();
renderGaCalculator();
renderGaStats();
};
calcWeight.addEventListener("input", onCalcInput);
calcTarget.addEventListener("input", onCalcInput);
calcShow.addEventListener("change", onCalcInput);
applyGaCalcState(panel);
applyGradeAnalyticsImagineState(panel);
applyOpenState();
// If the data finished loading before this panel was created (or before
// the stored open state was restored), the earlier render call found no
// panel — draw the charts now that it exists.
if (gaOpen && gaData) renderGradeAnalytics();
// A chart skipped because its box had no size (page still laying out,
// tab/panel hidden) is left at width 0; this observer redraws the moment
// the boxes get real dimensions, even when no DOM mutation or window
// resize follows.
if (gaChartObserver) gaChartObserver.disconnect();
gaChartObserver = new ResizeObserver(() => {
if (!gaOpen || !gaData) return;
const pie = panel.querySelector("#canvasrefined-ga-pie");
const line = panel.querySelector("#canvasrefined-ga-line");
if ((pie && pie.width === 0) || (line && line.width === 0)) renderGradeAnalytics();
});
gaChartObserver.observe(panel.querySelector("#canvasrefined-ga-pie").parentNode);
gaChartObserver.observe(panel.querySelector("#canvasrefined-ga-line").parentNode);
return panel;
}
async function loadGradeAnalytics() {
const courseId = getCurrentCourseId();
if (courseId == null) return;
gaLoading = true;
const status = document.getElementById("canvasrefined-ga-status");
if (status) status.textContent = "Reading grade data…";
try {
// Parse the grades table the page already rendered instead of hitting
// the paginated API — instant even for courses with hundreds of
// assignments, and always in sync with what the page shows.
const table = await gaWaitForGradesTable();
gaCourseId = courseId;
gaData = computeGradeAnalyticsFromPage(table);
gaLoading = false;
// renderGradeAnalytics self-guards on panel existence and canvas
// size; if the panel isn't ready yet, the retry in
// ensureGradeAnalyticsPanel / syncGradeAnalyticsUI draws once it is.
renderGradeAnalytics();
} catch (err) {
logError(err);
const s = document.getElementById("canvasrefined-ga-status");
if (s) s.textContent = "Grade Analytics failed to load: " + (err && err.message ? err.message : err);
} finally {
gaLoading = false;
}
}
// Parses one assignment row of #grades_summary into a plain record. The row
// stashes the original posted score in a hidden "original_score" span (the
// "original_points" span holds points EARNED, not possible), while points
// possible is only in the "/ 15" span displayed after the grade.
function gaParseNum(t) {
if (!t) return null;
let s = String(t).replace(/\s+/g, "");
if (s === "" || !/\d/.test(s)) return null;
// Normalize "1,234.5" (thousands grouping) and "9,5" (comma decimal).
if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
else if (/^-?\d+,\d+$/.test(s)) s = s.replace(",", ".");
const v = parseFloat(s);
return isFinite(v) ? v : null;
}
function gaParseAssignmentRow(tr) {
const q = (sel) => tr.querySelector(sel);
const titleLink = q(".title a");
const possibleText = q(".tooltip .grade + span")?.textContent || "";
return {
title: (titleLink ? titleLink.textContent : (q("th.title")?.textContent || "")).trim(),
score: gaParseNum(q(".original_score")?.textContent),
points: gaParseNum(possibleText.replace(/^.*\//, "")),
// Rows the page lists as unsubmitted/unposted carry no score; only
// "graded" rows have one.
status: (q(".submission_status")?.textContent || "").trim(),
gid: (q(".assignment_group_id")?.textContent || "").trim(),
due: (q("td.due")?.textContent || "").replace(/\s+/g, " ").trim(),
};
}
function computeGradeAnalyticsFromPage(table) {
// Assignment group weights come from the "group total" summary rows
// (e.g. "Summative Assessment — 86.67%, weight 85").
const groupWeight = {};
for (const tr of table.querySelectorAll("tr.group_total")) {
const gid = tr.querySelector(".assignment_group_id")?.textContent.trim();
const w = parseFloat((tr.querySelector(".group_weight")?.textContent || "").trim());
if (gid) groupWeight[gid] = isFinite(w) ? w : 0;
}
const totalWeight = Object.values(groupWeight).reduce((s, w) => s + w, 0);
// The page's own computed Total (e.g. "91.1%") — use it directly so the
// "Overall grade" stat always matches the page.
let pageTotal = null;
const totalText = table.querySelector("tr.final_grade .grade")?.textContent || "";
const totalMatch = totalText.match(/-?\d+(?:\.\d+)?/);
if (totalMatch) pageTotal = parseFloat(totalMatch[0]);
// Rows are already listed in due-date order; skip the summary rows (they
// carry the student_assignment class too).
const rows = [...table.querySelectorAll("tr.student_assignment")]
.filter(tr => !tr.classList.contains("group_total") && !tr.classList.contains("final_grade"))
.map(gaParseAssignmentRow);
const counts = GA_BUCKETS.map(() => 0);
let ungraded = 0;
const graded = [];
for (const a of rows) {
if (a.points == null || a.points <= 0) continue; // no points possible
if (a.score == null) { ungraded++; continue; } // unposted / unsubmitted
graded.push(a);
const pct = (a.score / a.points) * 100;
const idx = GA_BUCKETS.findIndex(b => pct >= b.min && pct < b.max);
counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++;
}
// Running overall grade, in the page's row order, using Canvas's own
// weighting algorithm (GradeCalculator): sum each group's pct × weight
// over groups that have graded work, then scale up to 100% only when
// those weights total less than 100 (weights over 100 are used raw and
// can push the grade past 100). Verified to reproduce the Total shown
// on the page. Point-based courses (no group weights) use points
// earned / points possible.
const running = {};
const pointsGrade = () => {
let s = 0, p = 0;
for (const g of Object.values(running)) { s += g.score; p += g.pts; }
return p > 0 ? (s / p) * 100 : null;
};
const points = graded.map(a => {
const r = (running[a.gid] ||= { score: 0, pts: 0 });
r.score += a.score;
r.pts += a.points;
let grade = null;
if (totalWeight > 0) {
let weighted = 0, fullWeight = 0;
for (const gid of Object.keys(running)) {
const g = running[gid];
if (g.pts <= 0) continue;
const w = groupWeight[gid] || 0;
weighted += (g.score / g.pts) * w;
fullWeight += w;
}
// Only zero-weighted groups have graded work — fall back to
// points so the chart still has a line.
grade = fullWeight > 0 ? (fullWeight < 100 ? (weighted / fullWeight) * 100 : weighted) : pointsGrade();
} else {
grade = pointsGrade();
}
return {
title: a.title,
score: a.score,
points: a.points,
pct: (a.score / a.points) * 100,
grade,
due: a.due,
};
});
const pcts = graded.map(a => (a.score / a.points) * 100);
// Trend: change in the running overall grade over the last 5 graded
// assignments (or since the first, if fewer). Positive = climbing.
let trend = null;
if (points.length >= 2) {
const from = points[Math.max(0, points.length - 1 - 5)].grade;
const to = points[points.length - 1].grade;
if (from != null && to != null) trend = to - from;
}
return {
counts,
ungraded,
graded: graded.length,
avg: pcts.length ? pcts.reduce((s, p) => s + p, 0) / pcts.length : null,
current: pageTotal != null ? pageTotal : (points.length ? points[points.length - 1].grade : null),
trend,
points,
};
}
// Stat cards row on the Overview tab. The optional "Grade goal" card appears
// when the final calculator's "show" checkbox is on; it recomputes against
// the live current grade so it stays accurate as new grades come in.
function renderGaStats() {
const panel = document.getElementById("canvasrefined-grade-analytics");
if (!panel || !gaData) return;
const stats = panel.querySelector("#canvasrefined-ga-stats");
if (!stats) return;
stats.style.display = "flex";
const stat = (cap, val, color) =>
`
${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)"; }
}
// Grade goal card from the Final Calculator tab: the score needed on the
// final to hit the stored target grade.
let goalStat = "";
if (gaCalc && gaCalc.show && gaCalc.weight > 0 && gaCalc.target != null && gaData.current != null) {
const w = gaCalc.weight / 100;
const needed = (gaCalc.target - gaData.current * (1 - w)) / w;
let val, color;
if (needed <= 0) { val = "\u2713 Secured"; color = "#16a34a"; }
else if (needed > 100) { val = "Out of reach"; color = "#dc2626"; }
else { val = "\u2265 " + needed.toFixed(1) + "%"; color = gaNeededColor(needed); }
goalStat = stat("Final Exam", val, color);
}
stats.innerHTML =
stat("Overall grade", gaData.current == null ? "-" : gaData.current.toFixed(1) + "%") +
stat("Grade trend (last 5)", trendVal, trendColor) +
stat("Graded", gaData.graded) +
stat("Ungraded", gaData.ungraded) +
goalStat;
}
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";
renderGaStats();
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"));
renderGaHeatmap();
renderGaCalculator();
}
// --- Final Calculator tab --------------------------------------------------
// Inline style for one panel tab button; the active tab gets the accent
// underline, matching how the rest of the panel is styled inline.
function gaTabStyle(active) {
return `background:transparent;border:none;padding:6px 14px;font-size:14px;font-weight:600;cursor:pointer;color:${active ? "var(--bctext-0)" : "var(--bctext-1)"};border-bottom:2px solid ${active ? "#2563eb" : "transparent"};`;
}
function gaSetTab(tab) {
gaTab = tab;
const panel = document.getElementById("canvasrefined-grade-analytics");
if (!panel) return;
for (const name of ["overview", "calc", "heatmap"]) {
const el = panel.querySelector(`#canvasrefined-ga-tab-${name}`);
if (el) el.style.display = name === tab ? "" : "none";
}
panel.querySelectorAll("[data-ga-tab]").forEach(btn => {
const active = btn.dataset.gaTab === tab;
btn.style.color = active ? "var(--bctext-0)" : "var(--bctext-1)";
btn.style.borderBottomColor = active ? "#2563eb" : "transparent";
});
if (tab === "overview") {
// The canvases were zero-size while the tab was hidden; redraw now
// that it's visible again.
if (gaOpen && gaData) renderGradeAnalytics();
} else if (tab === "heatmap") {
renderGaHeatmap();
} else {
renderGaCalculator();
}
}
// Severity color for an arbitrary score percentage — the same palette the
// doughnut / zone charts use, so a 70 renders yellow, an 85 green, etc.
function gaSeverityColor(pct) {
if (pct == null || !isFinite(pct)) return "var(--bctext-0)";
const b = GA_BUCKETS.find(b => pct >= b.min && pct < b.max);
return (b || GA_BUCKETS[GA_BUCKETS.length - 1]).color;
}
// Inverted severity for the final calculator: a LOW required score is good
// (needing only 10% on the final is comfortably green), a high one is bad.
// Own scale, independent of the grade chart palette: needing ≥95% on the
// final is red, 75–94% yellow, and ≤74% green.
const GA_NEEDED_COLORS = [
{ min: 95, color: "#dc2626" },
{ min: 75, color: "#facc15" },
{ min: 0, color: "#16a34a" },
];
function gaNeededColor(needed) {
if (needed == null || !isFinite(needed)) return "var(--bctext-0)";
const b = GA_NEEDED_COLORS.find(b => needed >= b.min);
return (b || GA_NEEDED_COLORS[GA_NEEDED_COLORS.length - 1]).color;
}
// Pushes the stored calculator settings into the tab's inputs without
// clobbering a field the user is actively typing in.
function applyGaCalcState(panel) {
const w = panel.querySelector("#canvasrefined-ga-calc-weight");
const t = panel.querySelector("#canvasrefined-ga-calc-target");
const s = panel.querySelector("#canvasrefined-ga-calc-show");
if (!w || !t || !s) return;
if (document.activeElement !== w) w.value = gaCalc && gaCalc.weight != null ? gaCalc.weight : "";
if (document.activeElement !== t) t.value = gaCalc && gaCalc.target != null ? gaCalc.target : "";
s.checked = !!(gaCalc && gaCalc.show);
renderGaCalculator();
}
// Renders the calculator result: needed = (target − current·(1−w)) / w, where
// w is the final's weight. Always recomputed from the live current grade so
// stored goals stay accurate after reloads and as new grades post.
function renderGaCalculator() {
const panel = document.getElementById("canvasrefined-grade-analytics");
if (!panel) return;
const box = panel.querySelector("#canvasrefined-ga-calc-result");
if (!box) return;
if (!gaCalc || gaCalc.weight == null || !(gaCalc.weight > 0) || gaCalc.target == null) {
box.innerHTML = `Enter your final's weight and your target grade to see what you need on the final.`;
return;
}
const w = gaCalc.weight / 100;
const target = gaCalc.target;
const current = gaData ? gaData.current : null;
if (current == null) {
box.innerHTML = `Waiting for grade data…`;
return;
}
const needed = (target - current * (1 - w)) / w;
const withZero = current * (1 - w);
const withPerfect = withZero + 100 * w;
let head, sub;
if (needed <= 0) {
head = `You're already there!`;
sub = `Even a 0 on the final leaves you at ${withZero.toFixed(1)}%, which is above your ${target}% goal.`;
} else if (needed > 100) {
head = `Out of reach`;
sub = `Even a perfect final only gets you to ${withPerfect.toFixed(1)}%, which is below your ${target}% goal.`;
} else {
head = `You need ≥ ${needed.toFixed(1)}% on the final`;
sub = `You got this!`;
}
box.innerHTML = head + `
${sub}
`;
}
// --- Heatmap tab ----------------------------------------------------------
// One week cell in the heatmap strip: one square per Sunday–Saturday week,
// laid out left to right — 16px squares with 4px gaps. Weekly (not daily)
// buckets keep a whole semester compact instead of a sparse daily grid.
// One day cell in the calendar heatmap: 13px squares with 3px gaps,
// GitHub-style columns of Sunday–Saturday weeks.
const GA_HM_CELL = 13;
const GA_HM_GAP = 3;
const GA_HM_MONTH_H = 16; // vertical room for the month labels above the grid
let gaHeatmapToken = null; // identifies the data the grid was last built from
// Color for a day's average score — the same 5%-banded red→green palette
// the line chart's zones use, so an 84% day matches an 84% zone.
function gaHeatmapColor(avg) {
return GA_ZONE_COLORS[Math.max(0, Math.min(GA_ZONE_COLORS.length - 1, Math.floor(avg / 5)))];
}
// Minimal HTML escaping for assignment titles that go into tooltips.
function gaEscHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
}
// Groups the graded assignments by due DATE. The grades table only renders
// "Sep 23"-style due dates (no year), so years are reconstructed: the table
// rows are in chronological order, so a month that steps backwards means the
// calendar wrapped into a new year; the base year is then chosen so the last
// due date isn't more than ~6 weeks in the future. Returns null when no
// graded assignment has a parseable due date.
function gaBuildHeatmapData() {
if (!gaData || !Array.isArray(gaData.points) || !gaData.points.length) return null;
const parsed = [];
for (const p of gaData.points) {
const m = /^([A-Za-z]{3})\s+(\d{1,2})/.exec((p.due || "").trim());
if (!m) continue;
const mo = months.findIndex(x => x.toLowerCase() === m[1].toLowerCase());
const day = parseInt(m[2], 10);
if (mo < 0 || day < 1 || day > 31) continue;
parsed.push({ p, mo, day });
}
if (!parsed.length) return null;
// Reconstruct years WITHOUT assuming the rows are in due-date order —
// the table isn't sorted by date when Canvas arranges it by assignment
// group, and the old "month stepped backwards ⇒ new year" detection
// snowballed on that, landing dates decades off. A course spans at most
// ~12 months, so instead: find the month rotation that packs every due
// month into the shortest window (months before the rotation wrap into
// the following year), then pick the base year whose window actually
// contains today — or, failing that, the latest window entirely in the
// past.
const present = [...new Set(parsed.map(e => e.mo))];
let rho = 0, bestSpan = 12;
for (let r = 0; r < 12; r++) {
let lo = 12, hi = -1;
for (const m of present) {
const u = (m - r + 12) % 12;
if (u < lo) lo = u;
if (u > hi) hi = u;
}
if (hi - lo < bestSpan) { bestSpan = hi - lo; rho = r; }
}
const thisYear = new Date().getFullYear();
const now = Date.now(), grace = 45 * 86400000;
const mkDate = (e, y) => new Date(y + (e.mo < rho ? 1 : 0), e.mo, e.day);
let base = null;
for (let y = thisYear + 1; y >= thisYear - 2 && base == null; y--) {
const ds = parsed.map(e => mkDate(e, y).getTime());
if (Math.min(...ds) <= now && now <= Math.max(...ds) + grace) base = y;
}
if (base == null) {
for (let y = thisYear; y >= thisYear - 3 && base == null; y--) {
if (parsed.every(e => mkDate(e, y).getTime() <= now + grace)) base = y;
}
}
if (base == null) base = thisYear;
const abs = (e) => mkDate(e, base);
// Bucket by calendar day; multiple assignments due the same day pool into
// one cell colored by their average score.
const byDay = new Map();
let min = null, max = null;
for (const e of parsed) {
const d = abs(e);
const key = d.getTime();
let cell = byDay.get(key);
if (!cell) {
cell = { date: d, sum: 0, items: [] };
byDay.set(key, cell);
if (min == null || d.getTime() < min.getTime()) min = d;
if (max == null || d.getTime() > max.getTime()) max = d;
}
cell.sum += e.p.pct;
cell.items.push(e.p);
}
return { byDay, min, max, undated: gaData.points.length - parsed.length };
}
// Tooltip anchored beside the hovered day cell. Positioning is relative to
// the heatmap tab (the tip's offsetParent), NOT the viewport: position:fixed
// is unreliable here because ancestors with transforms/backdrop-filters
// (Better Sidebar's glass panels, Canvas layout) redefine the containing
// block, which sent a fixed-position tooltip to the wrong part of the page.
function gaHeatmapShowTip(tip, e, cell, avg) {
const d = cell.date;
const rows = cell.items.map(p =>
`
${rows}`;
tip.style.display = "block";
const host = tip.offsetParent || tip.parentNode;
const hostRect = host.getBoundingClientRect();
const cellRect = e.target.getBoundingClientRect();
const tw = tip.offsetWidth, th = tip.offsetHeight;
// Beside the cell, vertically centered on it; flip to the left when the
// right edge is tight, and clamp inside the tab's box.
let left = cellRect.right - hostRect.left + 6;
if (left + tw > host.clientWidth - 4) left = Math.max(4, cellRect.left - hostRect.left - tw - 6);
let top = cellRect.top - hostRect.top + (cellRect.height - th) / 2;
top = Math.min(Math.max(4, top), Math.max(4, host.clientHeight - th - 4));
tip.style.left = left + "px";
tip.style.top = top + "px";
}
// Builds the GitHub-style calendar: weekday labels down the left (Mon/Wed/
// Fri only), one column per Sunday–Saturday week, month labels across the
// top, one colored square per day (that day's average score). Pure DOM (no
// canvas) so it needs no redraw on resize; a token guards against rebuilds
// when renderGradeAnalytics re-fires for the same data.
function renderGaHeatmap() {
const panel = document.getElementById("canvasrefined-grade-analytics");
if (!panel) return;
const grid = panel.querySelector("#canvasrefined-ga-heatmap-grid");
const note = panel.querySelector("#canvasrefined-ga-heatmap-note");
const tip = panel.querySelector("#canvasrefined-ga-heatmap-tip");
if (!grid || !note || !tip) return;
const data = gaBuildHeatmapData();
const token = data ? `${gaCourseId}:${data.min.getTime()}:${data.max.getTime()}:${gaData.points.length}` : "none";
if (grid.childElementCount && gaHeatmapToken === token) return; // already built
gaHeatmapToken = token;
grid.textContent = "";
if (!data) {
note.textContent = "No graded assignments with due dates yet.";
return;
}
note.textContent = data.undated > 0
? `${data.undated} graded assignment${data.undated === 1 ? "" : "s"} without a due date not shown.`
: "";
// Align the range out to full Sunday–Saturday weeks so columns never
// start mid-week.
const start = new Date(data.min);
start.setDate(start.getDate() - start.getDay());
const end = new Date(data.max);
end.setDate(end.getDate() + (6 - end.getDay()));
// Left weekday labels — sparse like GitHub's (Mon/Wed/Fri).
const labels = document.createElement("div");
labels.style.cssText = `display:flex;flex-direction:column;gap:${GA_HM_GAP}px;padding-top:${GA_HM_MONTH_H}px;`;
["", "Mon", "", "Wed", "", "Fri", ""].forEach(t => {
const l = document.createElement("div");
l.style.cssText = `height:${GA_HM_CELL}px;font-size:9px;line-height:${GA_HM_CELL}px;color:var(--bctext-1);white-space:nowrap;`;
l.textContent = t;
labels.appendChild(l);
});
grid.appendChild(labels);
const wrap = document.createElement("div");
wrap.style.cssText = `position:relative;padding-top:${GA_HM_MONTH_H}px;`;
const cols = document.createElement("div");
cols.style.cssText = `display:flex;gap:${GA_HM_GAP}px;`;
wrap.appendChild(cols);
grid.appendChild(wrap);
const cursor = new Date(start);
let wk = 0, prevMonth = null;
while (cursor.getTime() <= end.getTime()) {
const col = document.createElement("div");
col.style.cssText = `display:flex;flex-direction:column;gap:${GA_HM_GAP}px;`;
// Month label across the top when this week's Thursday enters a new
// month (every month owns at least one Thursday, so none are
// skipped).
const thursday = new Date(cursor);
thursday.setDate(thursday.getDate() + 4);
if (prevMonth == null || thursday.getMonth() !== prevMonth) {
prevMonth = thursday.getMonth();
const lab = document.createElement("div");
lab.textContent = months[prevMonth];
lab.style.cssText = `position:absolute;top:0;left:${wk * (GA_HM_CELL + GA_HM_GAP)}px;font-size:10px;line-height:1;color:var(--bctext-1);white-space:nowrap;`;
wrap.appendChild(lab);
}
for (let d = 0; d < 7; d++) {
const cell = data.byDay.get(cursor.getTime());
const div = document.createElement("div");
div.className = "canvasrefined-ga-hcell";
div.style.cssText = `width:${GA_HM_CELL}px;height:${GA_HM_CELL}px;border-radius:3px;background:${cell ? gaHeatmapColor(cell.sum / cell.items.length) : "color-mix(in srgb, var(--bctext-1) 20%, transparent)"};`;
if (cell) {
const avg = cell.sum / cell.items.length;
div.addEventListener("mousemove", (e) => gaHeatmapShowTip(tip, e, cell, avg));
div.addEventListener("mouseleave", () => { tip.style.display = "none"; });
}
col.appendChild(div);
cursor.setDate(cursor.getDate() + 1);
}
cols.appendChild(col);
wk++;
}
}
// --- 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 —
// but mark the canvas "never drawn" (width 0) so the self-heal paths
// (syncGradeAnalyticsUI and the chart ResizeObserver) know to retry once
// it has real dimensions. A canvas that was never drawn defaults to
// width 300, so without this the zero-width "never drawn" check never
// matches and charts stay blank until a tab switch or panel toggle.
if (w <= 0 || h <= 0) {
canvas.width = 0;
canvas.height = 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);
// Optional colored 5% zones behind the plot ("Colored grade zones"
// popup option), tinted red→green. With Fit Y axis on, bands are
// clipped to the visible range.
if (options.grade_analytics_zones) {
GA_ZONE_COLORS.forEach((color, i) => {
const top = Math.min(yMax, (i + 1) * 5);
const bottom = Math.max(yMin, i * 5);
if (top <= bottom) return;
const y1 = pad.t + (1 - (top - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
const y2 = pad.t + (1 - (bottom - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
ctx.globalAlpha = 0.3;
ctx.fillStyle = color;
ctx.fillRect(pad.l, y1, w - pad.l - pad.r, y2 - y1);
ctx.globalAlpha = 1;
});
}
// 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 axis: tick marks plus one label per calendar month. Points are
// already in chronological order, so a walking month counter (wrapping
// across Dec -> Jan) maps each point to an absolute month; the first
// point of each month gets the tick + label.
ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.strokeStyle = "rgba(128,128,128,0.55)";
ctx.lineWidth = 1;
const tick = (x) => {
ctx.beginPath(); ctx.moveTo(x, h - pad.b); ctx.lineTo(x, h - pad.b + 4); ctx.stroke();
};
// Subtle tick under every data point on sparse charts.
if (pts.length <= 25) pts.forEach((_, i) => tick(X(i)));
// Month boundaries: the first point of each calendar month. Points are
// already in chronological order, so a walking month counter (wrapping
// across Dec → Jan) maps each point to an absolute month.
const boundaries = [];
let prevM = null, absM = null;
pts.forEach((p, i) => {
const m = months.indexOf((p.due || "").trim().slice(0, 3));
if (m < 0) return; // no due date on this point
if (absM == null) absM = m;
else if (m >= prevM) absM += m - prevM;
else absM += 12 - prevM + m; // wrapped to a new year
prevM = m;
const last = boundaries[boundaries.length - 1];
if (!last || last.absM !== absM) {
boundaries.push({ i, absM, label: (p.due || "").trim().slice(0, 3) });
}
});
boundaries.forEach(b => tick(X(b.i)));
// Collision-aware labels: greedily keep a month label only if it fits
// after the previous kept one (labels are centered, so compare against
// half-widths plus a 6px gap). The first boundary always gets a label;
// so does the last — if it doesn't fit, earlier labels are dropped to
// make room, so the axis ends on a real month instead of mid-run.
const GAP = 6;
const kept = [];
boundaries.forEach((b, idx) => {
const x = X(b.i);
const w = ctx.measureText(b.label).width;
if (idx === 0 || x - w / 2 > kept[kept.length - 1].right + GAP) {
kept.push({ ...b, x, right: x + w / 2 });
}
});
const lastB = boundaries[boundaries.length - 1];
if (lastB && kept[kept.length - 1].i !== lastB.i) {
const x = X(lastB.i);
const w = ctx.measureText(lastB.label).width;
while (kept.length && kept[kept.length - 1].right + GAP > x - w / 2) kept.pop();
kept.push({ ...lastB, x, right: x + w / 2 });
}
kept.forEach((b, k) => {
// Anchor the edge labels inward so they don't clip.
ctx.textAlign = k === 0 ? "left" : (b.i === pts.length - 1 ? "right" : "center");
ctx.fillStyle = text;
ctx.fillText(b.label, b.x, 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();
});
// --- Imagine-If mode ---------------------------------------------------------
//
// A hypothetical-grade sandbox rendered directly on the grades table (never
// inside the Grade Analytics panel): each assignment row gets inline
// score / points-possible inputs plus a group selector and remove button,
// each group-total row gets a weight input and remove button, the "+ Add
// assignment" button sits at the top of the table (above the first
// assignment), and the "+ Add group" button sits above the Total row. The
// hypothetical total is computed here — Canvas's weighting algorithm
// (groups with counted work contribute pct × weight, scaled up to 100% when
// the used weights total less than 100; point-based totals when no group has
// weight) — and previewed by overwriting the table's Total row (the
// tr.final_grade percentage span) with an obvious Imagine-If badge. Nothing is ever sent to Canvas; the page is
// restored to the pixel the moment the mode is turned off.
// Standard grading scheme for the hypothetical letter grade (a course's
// actual scheme isn't exposed on this page). 80.5% → B− matches Canvas's
// default cutoffs.
const GA_LETTER_SCALE = [
["A", 93], ["A−", 90], ["B+", 87], ["B", 83], ["B−", 80],
["C+", 77], ["C", 73], ["C−", 70], ["D+", 67], ["D", 63], ["D−", 60],
];
function gaLetterFor(pct) {
if (pct == null || !isFinite(pct)) return null;
for (const [letter, min] of GA_LETTER_SCALE) if (pct >= min) return letter;
return "F";
}
// The table's Total row (tr.final_grade) — the recalculated grade
// overwrites its percentage span while Imagine-If mode is on.
function gaFindTotalGradeSpan() {
const row = document.querySelector("#grades_summary tr.final_grade");
return row ? row.querySelector("td.assignment_score .tooltip .grade") : null;
}
function gaFindTotalTitleCell() {
const row = document.querySelector("#grades_summary tr.final_grade");
return row ? row.querySelector("th.title") : null;
}
// Signature of the page's grades table (row ids). When it changes (grading
// period switch, SPA navigation) the scenario is rebuilt from the new table.
function gaImagineTableSig() {
const table = document.querySelector("#grades_summary");
return table ? [...table.querySelectorAll("tr.student_assignment")].map(tr => tr.id).join(",") : "";
}
// Snapshot of the page's groups and assignments the scenario starts from.
// Scores come from the hidden "original_score" spans (immune to Canvas's own
// What-If edits); ungraded rows keep score: null so they only count once the
// user types a score for them.
function gaBuildImagineScenario() {
const table = document.querySelector("#grades_summary");
if (!table) return null;
const groups = [];
for (const tr of table.querySelectorAll("tr.group_total")) {
const gid = (tr.querySelector(".assignment_group_id")?.textContent || "").trim();
if (!gid) continue;
const name = (tr.querySelector("th.title")?.textContent || "").trim() || ("Group " + gid);
const w = parseFloat((tr.querySelector(".group_weight")?.textContent || "").trim());
groups.push({ gid, name, weight: isFinite(w) ? w : 0 });
}
const assignments = [];
for (const tr of table.querySelectorAll("tr.student_assignment")) {
if (tr.classList.contains("group_total") || tr.classList.contains("final_grade")) continue;
const a = gaParseAssignmentRow(tr);
assignments.push({
key: tr.id || ("row-" + assignments.length),
title: (a.title || "Assignment").trim() || "Assignment",
score: a.score,
points: a.points,
gid: (a.gid || "").trim(),
});
}
return { groups, assignments };
}
// Hypothetical total for the scenario, computed from scratch (never read
// from the page's Total). Only assignments with a score, a positive
// denominator, and a group that still exists count. Weighted when any
// group has weight (Canvas's GradeCalculator: sum pct × weight over groups
// with counted work, scale up to 100% when the used weights total < 100,
// use raw when ≥ 100), otherwise points earned / points possible.
function gaComputeImagineTotal() {
if (!gaScenario) return null;
const groups = new Map();
let totalWeightAll = 0;
for (const g of gaScenario.groups) {
if (g.deleted) continue;
const w = isFinite(g.weight) ? g.weight : 0;
groups.set(String(g.gid), w);
totalWeightAll += w;
}
// With every group removed the total falls back to plain points, so all
// assignments count regardless of their (dangling) group id.
const noGroups = groups.size === 0;
const stats = new Map(); // gid -> {score, pts}
for (const a of gaScenario.assignments) {
if (a.deleted || a.score == null || !(a.points > 0)) continue;
const gid = String(a.gid ?? "");
if (!noGroups && !groups.has(gid)) continue; // group deleted / unassigned
const s = stats.get(gid) || { score: 0, pts: 0 };
s.score += a.score;
s.pts += a.points;
stats.set(gid, s);
}
if (!stats.size) return null;
if (totalWeightAll > 0) {
let weighted = 0, used = 0;
for (const [gid, s] of stats) {
const w = groups.get(gid) || 0;
weighted += (s.score / s.pts) * w;
used += w;
}
if (used > 0) return used < 100 ? (weighted / used) * 100 : weighted;
// Only zero-weighted groups have counted work — fall back to points.
}
let score = 0, pts = 0;
for (const s of stats.values()) { score += s.score; pts += s.pts; }
return pts > 0 ? (score / pts) * 100 : null;
}
// Total-row content for the hypothetical grade — clearly not the real
// grade: a purple Imagine-If badge and the hypothetical total (with letter)
// replace the percentage span, and an italic note under the "Total" label
// restates the real grade.
function gaImagineTotalHtml(pct) {
const letter = gaLetterFor(pct);
const pctText = pct == null || !isFinite(pct) ? "—" : pct.toFixed(1) + "%";
// The wrapper is an inline-flex row with align-items:center so the
// badge pill sits vertically centered with the grade text. No
// flex-wrap: the narrow score cell would stack the items instead.
return ``
+ `Imagine-If`
+ `${pctText}`
+ (letter ? `(${letter})` : "")
+ ``;
}
// Overwrites (or restores) the table's Total row. Writes are guarded so the
// GA DOM observer never loops: the last written HTML is cached on the
// element and identical writes are skipped.
function gaApplyImagineTotal() {
if (!gaImagineIf) { gaRestoreImagineTotal(); return; }
gaIfUpdateGroupPcts();
const span = gaFindTotalGradeSpan();
if (!span) return;
const html = gaImagineTotalHtml(gaComputeImagineTotal());
if (!(span.dataset.gaImagine && span._gaImagineHtml === html)) {
if (!span.dataset.gaImagine) gaOriginalFinalHtml = span.innerHTML;
span.dataset.gaImagine = "1";
span._gaImagineHtml = html;
span.innerHTML = html;
}
// Italic note under the "Total" label restating the real grade.
const th = gaFindTotalTitleCell();
if (th) {
let note = th.querySelector(".canvasrefined-ga-if-note");
if (!note) {
note = document.createElement("div");
note.className = "canvasrefined-ga-if-note";
note.style.cssText = "font-size:11px;font-style:italic;color:var(--bctext-1);margin-top:2px;";
th.appendChild(note);
}
if (note.dataset.gaIfHtml !== "Imagine-If scenario; not your actual grade.") {
note.dataset.gaIfHtml = "Imagine-If scenario; not your actual grade.";
note.textContent = "Imagine-If scenario; not your actual grade.";
}
}
}
function gaRestoreImagineTotal() {
const span = gaFindTotalGradeSpan();
if (span && span.dataset.gaImagine) {
delete span.dataset.gaImagine;
delete span._gaImagineHtml;
if (gaOriginalFinalHtml != null) span.innerHTML = gaOriginalFinalHtml;
}
document.querySelectorAll("#grades_summary tr.final_grade .canvasrefined-ga-if-note").forEach(n => n.remove());
}
// Compact inline styles for controls injected into the grades table.
// Canvas's table CSS gives selects/inputs an 11px bottom margin and a tall
// native select box, which made our rows taller than the page's own —
// margin:0 and a fixed select height keep the rows even.
const GA_IF_TD_INPUT = "box-sizing:border-box;padding:3px 6px;border-radius:5px;border:1px solid var(--bcborders);background:var(--bcbackground-1);color:var(--bctext-0);font-size:12px;margin:0;";
const GA_IF_TD_NUM = GA_IF_TD_INPUT + "width:58px;";
const GA_IF_TD_SEL = GA_IF_TD_INPUT + "max-width:170px;height:26px;padding:2px 4px;";
const GA_IF_TD_BTN = GA_IF_TD_INPUT + "cursor:pointer;white-space:nowrap;";
//