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 hiding, 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(); // Show/hide the Better Todo sidebar for the new page (course home vs // sub-page) without needing a full reload. updateTodoSidebarVisibility(); }; for (const method of ["pushState", "replaceState"]) { 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); } // The Better Todo sidebar belongs only on the main dashboard and each // course's homepage (/courses/, optional trailing slash) — not on // sub-pages like assignments, modules, quizzes or the syllabus. function isTodoAllowedPage() { if (current_page === "/" || current_page === "") return true; return /^\/courses\/\d+\/?$/.test(current_page); } // Show/hide an already-mounted Better Todo sidebar when the user navigates // client-side into or out of an allowed page (Canvas' New UI doesn't reload). function updateTodoSidebarVisibility() { const list = document.getElementById("canvasrefined-todo-list"); if (!list) return; list.style.display = isTodoAllowedPage() ? "" : "none"; } // Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and // /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; // 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); } // CSS-based hiding is the only mechanism: the style element persists across // Canvas re-renders and full reloads, and toggling the option off just removes // the style — the footer comes back instantly with no refresh. (An earlier // version also stripped the footer from the DOM on assignment pages, but that // made the toggle one-way until a reload, and display:none collapses the // footer gaplessly anyway — verified the content panel height shrinks by // exactly the footer height.) 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"; (document.head || document.documentElement).appendChild(style); } // #sequence_footer is the wrapper Canvas uses on assignment, discussion // and quiz show pages; page show (/courses/:id/pages/:slug) renders the // same Previous/Next bar without that wrapper, as a bare // .module-sequence-footer inside #module_navigation_target — so both // selectors are needed. style.textContent = "#sequence_footer,.module-sequence-footer{display:none!important}"; } else if (style) { style.remove(); } } function watchSequenceFooter() { applyHideSequenceFooter(); } // 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."*/; } // Canvas detection: if the domain is in the user's list, start normally. // Otherwise probe it (unless detection was declined for it); an empty list // auto-adds the first canvas, and with canvases already in the list the user // is asked once per domain. isDomainCanvasPage(); function isDomainCanvasPage() { chrome.storage.sync.get(['custom_domain', 'custom_domain_denied', 'auto_detect_disabled', 'dark_mode', 'dark_preset', 'device_dark', 'remind'], result => { options = result; const domains = (result.custom_domain || []).filter(d => d && d !== ""); const denied = result.custom_domain_denied || []; if (domains.some(d => domain.includes(d))) { startExtension(); return; } // not in the list — no prompting/probing if the user turned auto detect // off, or already said no here; just run reminders if (result.auto_detect_disabled === true || denied.some(d => domain.includes(d))) { startReminderMode(); return; } detectCanvasPage().then(({ isCanvas, courses }) => { if (!isCanvas) { startReminderMode(); return; } if (domains.length === 0) { // first canvas ever — add it automatically (original first-run // behavior) and reload so the extension starts addCanvasDomain(courses).then(() => location.reload()); } else { // at least one canvas already in the list: ask the first time // this page is seen. Saying no means we never ask again here. promptAddCanvasDomain(courses); } }); }); } function startReminderMode() { 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(); }) }) } // The courses API only answers on real Canvas installs. Resolves { isCanvas, courses }. async function detectCanvasPage() { try { const courses = await getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`); if (isCanvasCourseList(courses)) { return { isCanvas: true, courses }; } console.log("Canvas Refined - this url doesn't seem to be a canvas url (1)"); return { isCanvas: false, courses: null }; } catch (err) { console.log("Canvas Refined - this url doesn't seem to be a canvas url (2)"); return { isCanvas: false, courses: null }; } } // Verify the /api/v1/courses response actually looks like Canvas course // objects, not just "some JSON array". Real Canvas entries always carry a // numeric id, a name, and identifying fields (uuid/course_code/enrollments). // Without this check unrelated sites that coincidentally serve their own // JSON at the same path were misdetected as Canvas — e.g. // apstudents.collegeboard.org, whose AP-course list uses the shape // { course_id, course_name, course_url }. function isCanvasCourseList(courses) { return Array.isArray(courses) && courses.length > 0 && courses.every(c => c && c.id != null && typeof c.name === "string" && (c.uuid != null || c.course_code != null || Array.isArray(c.enrollments)) ); } // Register the domain in the canvas list. `courses` (from detectCanvasPage) is // passed to getCards so this school's courses are indexed before the reload, // exactly like the original first-run flow did. function addCanvasDomain(courses) { return getCards(courses).then(() => new Promise(resolve => { setTimeout(() => { console.log("Canvas Refined - setting custom domain to " + domain); chrome.storage.sync.get(["custom_domain"], storage => { const domains = (storage.custom_domain || []).filter(d => d && d !== ""); domains.push(domain); // the domain was just accepted — make sure it isn't on the // "don't ask again" list chrome.storage.sync.get(["custom_domain_denied"], deniedStorage => { const denied = (deniedStorage.custom_domain_denied || []).filter(d => !domain.includes(d)); chrome.storage.sync.set({ custom_domain: domains, custom_domain_denied: denied }).then(resolve); }); }); }, 100); })); } function promptAddCanvasDomain(courses) { // content scripts run at document_start, so the body may not exist yet const show = () => showCanvasPrompt(courses); if (document.body) show(); else document.addEventListener("DOMContentLoaded", show, { once: true }); } function showCanvasPrompt(courses) { if (document.getElementById("canvasrefined-domain-prompt-backdrop")) return; const host = domain.replace(/^https?:\/\//, ""); // modal with a backdrop so the ask is impossible to miss const backdrop = makeElement("div", document.body, { "id": "canvasrefined-domain-prompt-backdrop" }); const prompt = makeElement("div", backdrop, { "id": "canvasrefined-domain-prompt" }); makeElement("h3", prompt, { "textContent": "New Canvas detected" }); const text = makeElement("p", prompt, {}); text.append("Canvas Refined detected a Canvas page at "); makeElement("span", text, { "className": "canvasrefined-domain-host", "textContent": host }); text.append(". Add it to your canvas list?"); const actions = makeElement("div", prompt, { "className": "canvasrefined-domain-prompt-actions" }); const yes = makeElement("button", actions, { "textContent": chrome.i18n.getMessage("add_to_list") || "Add to list", "className": "canvasrefined-domain-yes" }); const no = makeElement("button", actions, { "textContent": chrome.i18n.getMessage("no_thanks") || "No thanks", "className": "canvasrefined-domain-no" }); const dismiss = () => backdrop.remove(); yes.addEventListener("click", () => { dismiss(); addCanvasDomain(courses).then(() => location.reload()); }); no.addEventListener("click", () => { dismiss(); chrome.storage.sync.get(["custom_domain_denied"], storage => { const denied = storage.custom_domain_denied || []; if (!denied.some(d => domain.includes(d))) denied.push(domain); chrome.storage.sync.set({ custom_domain_denied: denied }); }); startReminderMode(); }); } 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(); setupMobileNavStyle(); ensureBetterSidebar(); watchSequenceFooter(); watchProfileLogoutPageButton(); watchGradeAnalytics(); setupPersonalDetailsHiding(); 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 "hide_completed_cards": // Assignment elements are already preloaded; just re-filter the render. loadCardAssignments(); 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_timeframe_custom_past": case "todo_timeframe_custom_future": // 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 "hide_personal_details": case "anon_grade_level": case "anon_grade_seed": // "Reroll classes": the fake-course mapping is memoized, so a new // seed has to reset it before anything re-renders. if (key === "anon_grade_seed") anonCourseMap.clear(); // The GPA calculator caches its rendered state; drop the marker // so setupGPACalc below rebuilds it with fake/real values instead // of leaving the previous inputs in place. document.querySelectorAll(".canvasrefined-gpa-card, .canvasrefined-gpa").forEach(el => el.removeAttribute("data-canvasrefined-gpa-rendered")); // Starts/stops the DOM watcher; with it off, restores the real // text/attributes stashed in dataset markers. setupPersonalDetailsHiding(); // Todo item text, card grades and card assignment names are baked // in at render time, so re-render them to swap fake/real data. if (options.better_todo && document.getElementById("better-todo-main")) { moreAnnouncementCount = 0; moreAssignmentCount = 0; clearTodoList(); createTodoSections(document.querySelector("#canvasrefined-todo-list")); } if (!grades) getGrades(); insertGrades(); cardAssignments = preloadAssignmentEls(); loadCardAssignments(); break; case "anon_fake_todo": case "anon_fake_completed": case "anon_progress_enabled": case "anon_progress_done": case "anon_progress_total": // Adds/removes the fake todo items (or changes how many are done, // or rewrites the progress readout) without touching anything else. if (options.better_todo && document.getElementById("better-todo-main")) { moreAnnouncementCount = 0; moreAssignmentCount = 0; clearTodoList(); createTodoSections(document.querySelector("#canvasrefined-todo-list")); } break; case "hide_course_images": applyCourseImageHiding(); break; case "gpa_calc": case "gpa_calc_prepend": case "gpa_calc_weighted": case "gpa_calc_cumulative": if (!grades) getGrades(); setupGPACalc(); // The GPA elements participate in the card grid layout (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"); } // The mobile context nav + arrow button follow the same // "Hide Navigation Bar" gate — restyle to add/remove the // hide rules live. setupMobileNavStyle(); } 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 "card_grid_flex": 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(); setupMobileNavStyle(); 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); // Rings must match the list when fake items are shown. if (options.hide_personal_details === true && options.anon_fake_todo === true) { scopedData.push(...buildFakeTodoItems()); } 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(); setupMobileNavStyle(); } else { resetBetterSidebarLayout(); setupMobileNavStyle(); } 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; } /* Page show (/courses/:id/pages/:slug): Canvas paints h1.page-title and the Previous/Next module-sequence footer as opaque slabs, so with a custom background they sit as solid blocks on the glass content panel (dark mode also paints both var(--bcbackground-0) via darkmodecss.js). Flatten the title to match assignment/quiz titles (plain text on the glass panel) and give the sequence footer the same glass treatment as the other content surfaces. Selectors match darkmodecss.js exactly and this style element is appended after it, so these rules win the !important cascade tie. */ .pages.show .page-title, .page-title { background: none !important; } .module-sequence-footer .module-sequence-footer-content { 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; } .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 ("getGridCardCount"): { // Popup card-grid warning asks the real page instead of guessing // from stored card data: only cards actually rendered on this // dashboard that aren't display:none (hidden via the card menu) // occupy grid cells. Null means "no dashboard here" so the popup // falls back to its storage-derived count. try { const dashCards = Array.from(document.querySelectorAll(".ic-DashboardCard")); if (!dashCards.length) { sendResponse(null); break; } const visible = dashCards.filter(c => getComputedStyle(c).display !== "none"); sendResponse({ count: visible.length }); } catch (e) { sendResponse(null); } break; } case ("inspect"): sendResponse(inspectDarkMode(true)); break; case ("previewCelebration"): sendResponse(runCelebrationPreview()); 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 a window around now: one week of history back through // now+range (older overdue backlog is cut). 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, }; // How much history the preset timeframes include: the current window starts // this many days in the past so recently overdue work stays visible while // older backlog is cut. Custom timeframes pick their own "days ago" bound. const TODO_TIMEFRAME_LOOKBACK_DAYS = 4; // 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 (from the lookback bound — 1 week back for // presets, "X days ago" for custom — through the future edge). 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 window 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() { const allowed = ["all", "1week", "2week", "month", "2month", "custom"]; return allowed.includes(options.todo_timeframe) ? options.todo_timeframe : "all"; } // Custom timeframe bounds ("custom" option): the window runs from X days ago // to Y days in the future. Clamped to 0-365 days; the defaults match the // popup's initial inputs (1 week of history, 2 weeks ahead). function getTodoCustomTimeframeDays() { const past = parseInt(options.todo_timeframe_custom_past); const future = parseInt(options.todo_timeframe_custom_future); return { past: isNaN(past) ? 7 : Math.max(0, Math.min(365, past)), future: isNaN(future) ? 14 : Math.max(0, Math.min(365, future)), }; } // Total length of the current timeframe window in days ("all" = 0). Presets // cover their range ahead plus the shared 1-week lookback; custom windows are // as long as their past+future range. Either way the pager tiles windows back // to back. function getTodoTimeframeSpanDays() { const tf = getTodoTimeframeKey(); if (tf === "all") return 0; if (tf === "custom") { const c = getTodoCustomTimeframeDays(); return Math.max(1, c.past + c.future); } return BETTER_TODO_TIMEFRAME_DAYS[tf] + TODO_TIMEFRAME_LOOKBACK_DAYS; } // Days before "now" where the current window starts. Presets start at the // shared 1-week lookback (recently overdue work is kept, older backlog is // cut); custom windows start `past` days in the past per the user's setting. function getTodoTimeframePastDays() { const tf = getTodoTimeframeKey(); if (tf === "all") return 0; if (tf === "custom") return getTodoCustomTimeframeDays().past; return TODO_TIMEFRAME_LOOKBACK_DAYS; } // 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 runs from the lookback bound (1 week back for presets, // "X days ago" for custom) through the future edge, so recently overdue work // stays visible while older backlog is cut; offset N>0 shows only the strict // window (now+N*span, now+(N+1)*span] so the current timeframe can be paged // through with the look-ahead arrows. "all" keeps everything. // `lenientPast` (Announcements / Completed tabs): on the current window those // tabs keep everything on/before the cutoff — completed history and unread // announcements older than the lookback are still meaningful there — while // paging forward uses the same strict windows as Tasks. function applyTodoTimeframe(items, lenientPast) { betterTodoTimeframe = getTodoTimeframeKey(); if (betterTodoTimeframe === "all") return items; const dayMs = 24 * 60 * 60 * 1000; const now = Date.now(); const days = getTodoTimeframeSpanDays(); const pastDays = getTodoTimeframePastDays(); // End of the current window: the future edge (presets end `days` out, // custom windows end `future` days out; span - past = future in both). const cutoff = now + ((betterTodoTimeframeOffset + 1) * days - pastDays) * dayMs; // Start of the current window: the lookback bound (skipped for the // lenient tabs on the current window). if (lenientPast === true && betterTodoTimeframeOffset <= 0) { return items.filter(item => new Date(item.plannable_date).getTime() <= cutoff); } const windowStart = now + (betterTodoTimeframeOffset * days - pastDays) * dayMs; 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 = getTodoTimeframeSpanDays(); const offset = (tf === "all") ? 0 : betterTodoTimeframeOffset; const pastDays = getTodoTimeframePastDays(); const now = Date.now(); const start = new Date(now + (offset * days - pastDays) * 24 * 60 * 60 * 1000); const end = new Date(start.getTime() + 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 spanDays = getTodoTimeframeSpanDays(); const pastDays = getTodoTimeframePastDays(); const now = Date.now(); const start = now + (betterTodoTimeframeOffset * spanDays - pastDays) * 24 * 60 * 60 * 1000; return { start, end: start + spanDays * 24 * 60 * 60 * 1000 }; } // 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; } // Courses the user ticked "Hide this course from the Better Todo list" in the // card edit menu are dropped from the todo list and its progress display. // This is independent of dashboard visibility (isCourseHidden), so a course // can stay on the dashboard while its items stay off the todo list, and // vice versa. Personal tasks (planner notes with no course) are always kept. function isCourseTodoHidden(courseId) { if (courseId === undefined || courseId === null) return false; const cards = options.custom_cards || {}; const card = cards[String(courseId)] || cards[courseId]; return !!card && card.hide_todo === true; } function filterHiddenCourses(data) { return data.filter(item => { const cid = item.course_id || item.context_id || item?.plannable?.course_id; return !isCourseHidden(cid) && !isCourseTodoHidden(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 tab — // which keeps recently overdue work — finished work from before the // window is history, not current workload, so it never counts. The // window is a strict slice (from the lookback bound through the future // edge), so the counts always match the Tasks tab exactly. 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(); return t > bounds.start && t <= bounds.end; }); } 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); // "Fake progress display" (Hide personal details): when enabled, the // readout reports the user's chosen completed/total instead of the real // counts. Per-course rings are re-distributed around that ratio (with a // little per-course variation, so every ring isn't filled identically) // and the sums are corrected to add up to exactly x/y. if (options.hide_personal_details === true && options.anon_progress_enabled === true && shown.length > 0) { applyAnonProgressOverride(shown); } 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); // "Fill the todo list with fake courses": fake items join the same // scoped pipeline as real ones, so the Tasks list AND the progress // rings count them identically (rings read from scopedData too). if (options.hide_personal_details === true && options.anon_fake_todo === true) { scopedData.push(...buildFakeTodoItems()); } // 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. The // Tasks tab bounds the past (1 week of history back), while // Announcements/Completed keep their older history on the current // window and only narrow when paged forward. // 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)), false).concat(pinnedItems); announcements = applyTodoTimeframe(announcements, true); completed = applyTodoTimeframe(completed, true); // 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); // Fake todo items ("Fill the todo list with fake courses") have no real // Canvas page behind them, so never hit the API for them. if (item.anonFake === true) return "No details given"; // 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 = anonTitleForItem(item); 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 // "Hide personal details": don't reveal real assignment descriptions. text.textContent = options.hide_personal_details === true ? "Preview hidden while personal details are hidden." : 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; // "Hide personal details": never reveal the real earned score. const shownScore = options.hide_personal_details === true ? null : cached?.score; if (cached?.excused) text = "Excused"; else text = `${shownScore != null ? formatTodoScoreNumber(shownScore) : "–"}/${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; // "Hide personal details": never reveal the real earned score. const shownScore = options.hide_personal_details === true ? null : cached.score; if (cached.excused) el.textContent = "Excused"; else el.textContent = `${shownScore != null ? formatTodoScoreNumber(shownScore) : "–"}/${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 = item.anonFake === true ? item.anonColor // fake items carry their (real) course color : (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) : (item.anonFake === true ? "#" : 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 = `
${taskIcon}
${anonCourseNameForItem(item)} ${anonTitleForItem(item)} ${convertToDueDate(item.plannable_date)}${scoreBadge}
${editButtonSvg}
`; // The checkmark reflects the tab it is rendered in: on the Tasks tab it // marks the item complete; on the Completed tab it makes it incomplete // again (for genuinely submitted items this sets a local pin, since // Canvas has no way to un-submit). assignment.querySelector(".better-todo-assignment-checkmark").addEventListener("click", () => { if (item.anonFake === true) return; // fake items can't be completed in Canvas console.log("marking ", item.plannable.title, iscompleted ? "as incomplete" : "as complete"); markAs(item, assignment.firstElementChild, !iscompleted); }); const editBtn = assignment.querySelector(".better-todo-assignment-edit"); if (editBtn) { editBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openTaskForEdit(item); }); } attachTodoHoverPreview(assignment, item); }); if (options.todo_show_scores === true) loadTodoScores(iscompleted ? completed : assignmentsDue); if (document.getElementById("better-todo-see-more")) { document.getElementById("better-todo-see-more").remove(); } if (assignmentCount > maxElements) { let isExpanded = false; let seeMoreButton = makeElement("button", document.getElementById("better-todo-main"), { textContent: `View More (${assignmentCount - maxElements})`, className: "canvasrefined-custom-btn", id: "better-todo-see-more", style: "width:100%;margin-top:15px;cursor:pointer;" }) seeMoreButton.addEventListener("click", () => { if (!isExpanded) { document.querySelectorAll(".better-todo-hidden-assignment").forEach(element => element.style.display = "block"); document.querySelectorAll(".better-todo-hidden-wrapper").forEach(element => element.style.display = "block"); seeMoreButton.textContent = "View Less"; } else { document.querySelectorAll(".better-todo-hidden-assignment").forEach(element => element.style.display = "none"); document.querySelectorAll(".better-todo-hidden-wrapper").forEach(element => element.style.display = "none"); seeMoreButton.textContent = `View More (${assignmentCount - maxElements})`; } isExpanded = !isExpanded; }) } } function populateAnnouncements() { const today = new Date(); today.setHours(0,0,0,0); announcements.forEach((item) => { let dueGroup = item.plannable.read_state == "read" ? "Seen" : "New"; let announcement; // console.log(domContainers) const targetContainer = domContainers[dueGroup]; if (targetContainer) { targetContainer.wrapper.style.display = "block"; announcement = makeElement("div", targetContainer.listContainer, { class: "better-todo-announcement", }); } 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": black in light mode, theme text color in dark. const classNameColor = options.todo_ignore_card_colors ? (options.dark_mode === true ? "var(--bctext-0)" : "#000000") : courseColor; // "Remove icons": drop the announcement icon from the colored strip. const removeIcons = options.todo_remove_icons === true; let filter = ""; if (item.plannable.read_state == "read") { filter = "filter: grayscale(40%);" } announcement.innerHTML = `
${removeIcons ? "" : ` `}
${anonCourseNameForItem(item)} ${anonTitleForItem(item)} ${convertToDueDate(item.plannable_date)}
`; // "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); }); } // Particle effects (confetti, fireworks, sparkles) render into ONE fullscreen // per burst instead of individual DOM elements. Dozens of animated // layers compositing over Canvas's blurred dashboard is itself the lag; a // single canvas is one layer with cheap 2D draws, so even "crazy" volumes // stay smooth. function celebrationCanvas() { const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647;'; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.round(window.innerWidth * dpr); canvas.height = Math.round(window.innerHeight * dpr); document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return { canvas, ctx, w: window.innerWidth, h: window.innerHeight }; } // Shared rAF driver: runs `step(now)` until it returns false, then removes // the canvas (after a grace period so trailing fades finish). function runCelebrationCanvas(view, step, graceMs) { let rafId = null; const loop = (now) => { try { if (step(now)) rafId = requestAnimationFrame(loop); else { setTimeout(() => { try { view.canvas.remove(); } catch (e) { /* ignore */ } }, graceMs || 100); } } catch (e) { try { view.canvas.remove(); } catch (err) { /* ignore */ } } }; rafId = requestAnimationFrame(loop); // Hard stop so a backgrounded tab (rAF throttling) can't leak the canvas. setTimeout(() => { try { rafId && cancelAnimationFrame(rafId); view.canvas.remove(); } catch (e) { /* ignore */ } }, 9000); } function createConfettiBurst(targetElement, opts = {}) { try { const amountScale = celebrationAmountScale(); const count = Math.min(220, Math.round((opts.count || 48) * amountScale * (amountScale >= 4 ? 1.25 : amountScale >= 2 ? 1.2 : 1))); const durationFactor = amountScale >= 4 ? 1.6 : amountScale >= 2 ? 1.35 : amountScale < 1 ? 0.85 : 1; // Fan the burst out as the amount grows so bigger bursts don't read // as one dense clump. const spread = (opts.spread || 110) * (amountScale >= 4 ? 1.9 : amountScale >= 2 ? 1.45 : 1); const colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f']; const rect = targetElement.getBoundingClientRect(); const view = celebrationCanvas(); const ctx = view.ctx; const originX = rect.left + rect.width / 2; const originY = rect.top + rect.height * 0.35; const pieceScale = amountScale >= 4 ? 1.6 : amountScale >= 2 ? 1.35 : 1; const pieces = []; let maxEnd = 0; for (let i = 0; i < count; i++) { const bigPiece = Math.random() < 0.25 && pieceScale > 1 ? 1.9 : 1; const w = (4 + Math.floor(Math.random() * 7)) * pieceScale * bigPiece; const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8))); const duration = Math.round((850 + Math.random() * 500) * durationFactor); const delay = Math.floor(Math.random() * 160); const horizontalBias = (Math.random() - 0.5) * 2; 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) * (amountScale >= 2 ? 1.4 : 1); pieces.push({ delay, duration, ox: originX - w / 2, oy: originY - h / 2, endX, endY, ctrlX, ctrlY, w, h, rot: (Math.random() * 260) - 130, round: Math.random() > 0.75, sc: 0.8 + Math.random() * 0.5, color: colors[(Math.random() * colors.length) | 0], }); const end = delay + duration; if (end > maxEnd) maxEnd = end; } const startTime = performance.now(); runCelebrationCanvas(view, (now) => { ctx.clearRect(0, 0, view.w, view.h); let active = false; for (let i = 0; i < pieces.length; i++) { const pc = pieces[i]; const elapsed = now - startTime - pc.delay; if (elapsed < 0) { active = true; continue; } const p = Math.min(1, elapsed / pc.duration); if (p < 1) active = true; // Same arc as before: fast rise through the bezier peak, then // settle into the end point while fading out. const eased = 1 - Math.pow(1 - p, 3); const inv = 1 - eased; const x = inv * inv * pc.ox + 2 * inv * eased * pc.ctrlX + eased * eased * pc.endX; const y = inv * inv * pc.oy + 2 * inv * eased * pc.ctrlY + eased * eased * pc.endY; const sc = pc.sc * (1 - eased * 0.15); ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p); ctx.translate(x, y); ctx.rotate((pc.rot * eased * Math.PI) / 180); ctx.scale(sc, sc); ctx.fillStyle = pc.color; if (pc.round) { ctx.beginPath(); ctx.ellipse(0, 0, pc.w / 2, pc.h / 2, 0, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillRect(-pc.w / 2, -pc.h / 2, pc.w, pc.h); } ctx.restore(); } return active; }, 150); } catch (e) { console.error('confetti error', e); } } // --- Completion celebrations ------------------------------------------------ // Every effect renders into its own fixed-position container (pointer-events // none, topmost z-index) so it ignores page scroll and layout. Overlay-particle // effects keep the default task exit (slide right); "rocket" replaces the // task's own exit animation by flying the element off the page, so // runCelebration returns which exit the caller should apply. const CELEBRATION_EFFECT_IDS = ["confetti", "fireworks", "sparkles", "hearts", "rocket"]; // Intensity tiers for the Celebrations "Amount" dropdown. "less" halves the // particle counts; "more" doubles them and enlarges fireworks/hearts; "crazy" // quadruples with even bigger visuals. Rocket uses the raw factor for its // thruster size/length at every tier. const CELEBRATION_AMOUNT_SCALES = { less: 0.5, normal: 1, more: 2, crazy: 4 }; function celebrationAmountScale() { const v = options.todo_celebration_amount; return CELEBRATION_AMOUNT_SCALES[v] || 1; } function enabledCelebrationTypes() { // Master Celebrations toggle (default on). if (options.todo_celebrations_enabled === false) return []; // Legacy migration: before the Celebrations box there was a single // "show confetti on complete" checkbox (todo_confetti). const list = options.todo_celebrations; if (Array.isArray(list)) return list.filter(t => CELEBRATION_EFFECT_IDS.includes(t)); return options.todo_confetti === false ? [] : ["confetti"]; } // Picks a random enabled effect and runs it against the task element. // Returns the exit animation for the task element: // "slide" (default slide-out), "wave", "rocket" or "rainbow". // Small pause before the celebration fires and the exit animation starts, so // the task is actually seen before everything happens (without it, overlay // effects fired while the slide-out was already running, so the card seemed // to never appear). const CELEBRATION_LEAD_IN = 250; // How long after the effect starts the task begins its exit. const CELEBRATION_SLIDE_AFTER = 200; // Picks a random enabled effect and schedules it against the task element. // Returns the task's exit as { style, delay, startDelay, start }: "slide" is // the default slide-out, "rocket" flies the card off page. start/startDelay // run the overlay effect; delay holds the exit back so the effect can play // out first (hearts stay while they float). function runCelebration(targetElement) { const types = enabledCelebrationTypes(); if (!types.length) return { style: "slide", delay: 0 }; const type = types[Math.floor(Math.random() * types.length)]; ensureCelebrationStyles(); const lead = CELEBRATION_LEAD_IN; switch (type) { case "confetti": return { style: "slide", delay: lead + CELEBRATION_SLIDE_AFTER, startDelay: lead, start: () => createConfettiBurst(targetElement) }; case "fireworks": return { style: "slide", delay: lead + 350, startDelay: lead, start: () => createFireworksBurst(targetElement) }; case "sparkles": return { style: "slide", delay: lead + CELEBRATION_SLIDE_AFTER, startDelay: lead, start: () => createSparkleBurst(targetElement) }; // Keep the task on screen a little longer so the hearts get going, // but only about half their runtime — don't hold it hostage. case "hearts": return { style: "slide", delay: lead + Math.round(risingHeartsRuntime() * 0.5), startDelay: lead, start: () => createRisingHearts(targetElement) }; // The exhaust trail is spawned by applyTodoExitAnimation once the // flying clone exists (it needs to track the clone's position), so // the rocket has no separate start fn — its whole exit IS the effect. case "rocket": return { style: "rocket", delay: lead, startDelay: lead, start: null }; } return { style: "slide", delay: 0 }; } // Duration (ms) of each exit style's task-element animation. The todo list // re-render waits at least this long so the element isn't ripped out of the // DOM mid-animation ("slide" keeps the previous 400ms behavior). const CELEBRATION_EXIT_DURATIONS = { slide: 400, rocket: 1250 }; // Small headroom after the slide's transition ends before the list re-render // swaps the DOM, so the swipe always plays out fully. const CELEBRATION_EXIT_SETTLE = 60; function celebrationExitDuration(exitStyle, delay) { return (delay || 0) + (CELEBRATION_EXIT_DURATIONS[exitStyle] || CELEBRATION_EXIT_DURATIONS.slide) + CELEBRATION_EXIT_SETTLE; } function applyTodoExitAnimation(element, exitStyle, delay) { const run = () => { if (element.__crCelebrationCancelled === true) return; try { if (exitStyle === "rocket") { // The task launches off the page. The list container clips // overflow, so animating the real element would cut it off // halfway up — instead a fixed-position clone of the card is // flown off screen while the original just disappears in place. const rect = element.getBoundingClientRect(); // Crazy rockets fly farther (and their exhaust travels farther to // keep up with the higher speed). const distF = celebrationAmountScale() >= 4 ? 1.9 : 1; const clone = element.cloneNode(true); clone.classList.remove("canvasrefined-rocket-exit"); clone.style.setProperty("--cr-rx1", (2.5 * distF).toFixed(2) + "vw"); clone.style.setProperty("--cr-ry1", Math.round(-55 * distF) + "vh"); clone.style.setProperty("--cr-rx2", (8 * distF).toFixed(2) + "vw"); clone.style.setProperty("--cr-ry2", Math.round(-125 * distF) + "vh"); clone.style.position = "fixed"; clone.style.left = rect.left + "px"; clone.style.top = rect.top + "px"; clone.style.width = rect.width + "px"; clone.style.height = rect.height + "px"; clone.style.margin = "0"; clone.style.boxSizing = "border-box"; clone.style.pointerEvents = "none"; clone.style.zIndex = "2147483646"; document.body.appendChild(clone); element.__crRocketClone = clone; element.style.transition = "none"; element.style.opacity = "0"; requestAnimationFrame(() => clone.classList.add("canvasrefined-rocket-exit")); // Exhaust flames track the flying clone. createRocketCelebration(clone); setTimeout(() => { try { clone.remove(); } catch (e) { /* ignore */ } if (element.__crRocketClone === clone) element.__crRocketClone = null; }, 1500); } else { // Slide-out (delay was already consumed by the wrapper above). // The swipe relies on the row's inline "transition: all .4s ease"; // if that's missing (e.g. cleared by an earlier revert on this // element) the card would teleport instead of sliding, so make // sure a transition exists before moving it. if (getComputedStyle(element).transitionDuration === "0s") { element.style.transition = "transform .4s ease, opacity .4s ease"; } // Double rAF so the slide always starts from the painted state. requestAnimationFrame(() => requestAnimationFrame(() => { element.style.transform = "translate(100%)"; element.style.opacity = "0"; })); } } catch (e) { element.style.transform = "translate(100%)"; element.style.opacity = "0"; } }; if (delay > 0) { if (element.__crExitTimer) clearTimeout(element.__crExitTimer); element.__crExitTimer = setTimeout(() => { element.__crExitTimer = null; run(); }, delay); } else { run(); } } let celebrationStylesEl = null; function ensureCelebrationStyles() { if (celebrationStylesEl && document.getElementById("canvasrefined-celebration-styles")) return; celebrationStylesEl = document.createElement("style"); celebrationStylesEl.id = "canvasrefined-celebration-styles"; celebrationStylesEl.textContent = ` @keyframes canvasrefined-rocket-exit { 0% { transform: translate(0, 0) rotate(0deg); opacity: 1; } 12% { transform: translate(0, 12px) rotate(0deg); opacity: 1; } 60% { transform: translate(var(--cr-rx1, 2.5vw), var(--cr-ry1, -55vh)) rotate(4deg); opacity: 1; } 100% { transform: translate(var(--cr-rx2, 8vw), var(--cr-ry2, -125vh)) rotate(8deg); opacity: 1; } } .canvasrefined-rocket-exit { animation: canvasrefined-rocket-exit 1.2s cubic-bezier(.55, 0, .85, .4) forwards !important; } /* Rocket exhaust flames are animated with the Web Animations API (el.animate): literal keyframe values, so Chrome always runs them on the compositor. Confetti, firework and sparkle particles are canvas-rendered instead (see celebrationCanvas). */ .canvasrefined-rocket-flame { position: fixed; border-radius: 50%; pointer-events: none; z-index: 2147483647; } /* Sparkle bursts are canvas-rendered now (see celebrationCanvas); no DOM sparkle elements or keyframes are needed anymore. */ /* Outer span rises + fades; inner span sways side to side on an eased alternate loop, so the float reads as one smooth sinusoidal drift (single-element keyframes made the sway/rotation visibly change direction mid-flight). */ @keyframes canvasrefined-heart-rise { 0% { transform: translateY(6px) scale(.55); opacity: 0; animation-timing-function: cubic-bezier(.2, .7, .4, 1); } 6% { transform: translateY(0) scale(var(--cr-s, 1)); opacity: 1; animation-timing-function: cubic-bezier(.3, .6, .5, 1); } /* fast launch: a quarter of the climb happens in the first ~15% */ 15% { transform: translateY(calc(var(--cr-hy, -160px) * .28)) scale(var(--cr-s, 1)); } 80% { opacity: .92; } 100% { transform: translateY(var(--cr-hy, -160px)) scale(var(--cr-s, 1)); opacity: 0; } } @keyframes canvasrefined-heart-sway { 0% { transform: translateX(calc(var(--cr-hx, 14px) * -.5)) rotate(calc(var(--cr-rot, 9deg) * -1)); } 100% { transform: translateX(calc(var(--cr-hx, 14px) * .5)) rotate(var(--cr-rot, 9deg)); } } .canvasrefined-heart { position: absolute; pointer-events: none; line-height: 1; will-change: transform, opacity; animation: canvasrefined-heart-rise var(--cr-dur, 1400ms) var(--cr-cdelay, 0ms) both; } .canvasrefined-heart-inner { display: inline-block; animation: canvasrefined-heart-sway calc(var(--cr-dur, 1400ms) * .55) ease-in-out infinite alternate; } `; (document.head || document.documentElement).appendChild(celebrationStylesEl); } function celebrationContainer() { const container = document.createElement('div'); 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); return container; } function createFireworksBurst(targetElement) { try { const rect = targetElement.getBoundingClientRect(); const view = celebrationCanvas(); const ctx = view.ctx; const colors = ['#ff4d4f', '#ffc107', '#4dd0e1', '#7c4dff', '#ff6ec7', '#ffd54f', '#69f0ae']; const originX = rect.left + rect.width / 2; const originY = rect.top + rect.height * 0.4; const scale = celebrationAmountScale(); // more/crazy launch more shells AND bigger bursts; less only trims. const burstScale = scale > 1 ? (scale >= 4 ? 2.2 : 1.5) : 1; const shellCount = Math.max(1, Math.round(3 * scale)); const shellGap = 260 / Math.sqrt(scale); const shells = []; const sparks = []; const flashes = []; for (let i = 0; i < shellCount; i++) { shells.push({ start: i * shellGap + Math.random() * 130, apexX: originX + (Math.random() - 0.5) * 90 * burstScale, // Lower bursts overall, with per-shell height variation. apexY: originY - (55 + Math.random() * 110) * burstScale, duration: 320 + Math.random() * 160, color: colors[(Math.random() * colors.length) | 0], }); } const explode = (shell, now) => { flashes.push({ x: shell.apexX, y: shell.apexY, r: 26 * burstScale, born: now }); const count = Math.min(48, Math.round((26 + Math.random() * 12) * scale)); const life = 700 + Math.random() * 500; for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 * i) / count + Math.random() * 0.35; const speed = (60 + Math.random() * 100) * burstScale; sparks.push({ x: shell.apexX, y: shell.apexY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 25 * burstScale, size: (2 + Math.random() * 3) * burstScale, born: now, duration: life, color: shell.color, }); } }; const startTime = performance.now(); let last = startTime; let remainingShells = shells.length; runCelebrationCanvas(view, (now) => { const dt = Math.min(50, now - last) / 1000; last = now; ctx.clearRect(0, 0, view.w, view.h); let active = false; // shells climb from the task toward their apex for (let i = shells.length - 1; i >= 0; i--) { const sh = shells[i]; const elapsed = now - startTime - sh.start; if (elapsed < 0) { active = true; continue; } const p = Math.min(1, elapsed / sh.duration); const eased = 1 - Math.pow(1 - p, 2); const x = originX + (sh.apexX - originX) * eased; const y = originY + (sh.apexY - originY) * (1 - Math.pow(1 - eased, 1.6)); ctx.beginPath(); ctx.fillStyle = '#fffbe6'; ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fill(); if (p >= 1) { explode(sh, now); shells.splice(i, 1); } else active = true; } // soft radial flashes at each burst point for (let i = flashes.length - 1; i >= 0; i--) { const f = flashes[i]; const p = (now - f.born) / 300; if (p >= 1) { flashes.splice(i, 1); continue; } active = true; const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r); g.addColorStop(0, 'rgba(255,255,255,' + (0.85 * (1 - p)).toFixed(3) + ')'); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); ctx.fill(); } // sparks: simple ballistic drift with gravity + drag const gravity = 300; for (let i = sparks.length - 1; i >= 0; i--) { const sp = sparks[i]; const p = (now - sp.born) / sp.duration; if (p >= 1) { sparks.splice(i, 1); continue; } active = true; sp.x += sp.vx * dt; sp.y += sp.vy * dt; sp.vy += gravity * dt; sp.vx *= (1 - 1.1 * dt); ctx.globalAlpha = Math.max(0, 1 - p); ctx.fillStyle = sp.color; ctx.beginPath(); ctx.arc(sp.x, sp.y, sp.size * (1 - p * 0.6), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; return active || shells.length > 0 || (remainingShells-- > 0 && now - startTime < shellCount * shellGap + 1500); }, 200); } catch (e) { console.error('fireworks error', e); } } function createSparkleBurst(targetElement) { try { const rect = targetElement.getBoundingClientRect(); const view = celebrationCanvas(); const ctx = view.ctx; const colors = ['#ffe082', '#fff59d', '#ffd54f', '#ffffff', '#ff8ab5', '#b388ff']; const originX = rect.left + rect.width / 2; const originY = rect.top + rect.height * 0.4; const scale = celebrationAmountScale(); const sparkCount = Math.min(80, Math.round(22 * scale)); const sizeScale = scale >= 4 ? 1.5 : scale >= 2 ? 1.25 : 1; const sparks = []; for (let i = 0; i < sparkCount; i++) { const angle = Math.random() * Math.PI * 2; const dist = 40 + Math.random() * 95; sparks.push({ dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist * 0.7 - 18, size: (6 + Math.random() * 10) * sizeScale, rot: Math.random() * 360, dur: 700 + Math.random() * 500, delay: Math.floor(Math.random() * 120), color: colors[(Math.random() * colors.length) | 0], }); } const startTime = performance.now(); runCelebrationCanvas(view, (now) => { ctx.clearRect(0, 0, view.w, view.h); let active = false; for (let i = 0; i < sparks.length; i++) { const sp = sparks[i]; const elapsed = now - startTime - sp.delay; if (elapsed < 0) { active = true; continue; } const p = Math.min(1, elapsed / sp.dur); if (p < 1) active = true; // ease-out drift to the end point while scaling up then down const eased = 1 - Math.pow(1 - p, 2.2); const scalePulse = p < 0.3 ? 0.2 + (p / 0.3) * 0.8 : Math.max(0.1, 1 - ((p - 0.3) / 0.7) * 0.9); const x = originX + sp.dx * eased; const y = originY + sp.dy * eased; const r = sp.size * scalePulse; ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p * p); ctx.translate(x, y); ctx.rotate(((sp.rot + 220 * p) * Math.PI) / 180); // 4-point star path const q = r * 0.38; ctx.fillStyle = sp.color; ctx.beginPath(); ctx.moveTo(0, -r); ctx.quadraticCurveTo(q * 0.3, -q * 0.3, r, 0); ctx.quadraticCurveTo(q * 0.3, q * 0.3, 0, r); ctx.quadraticCurveTo(-q * 0.3, q * 0.3, -r, 0); ctx.quadraticCurveTo(-q * 0.3, -q * 0.3, 0, -r); ctx.fill(); ctx.restore(); } return active; }, 120); } catch (e) { console.error('sparkle error', e); } } // Total ms until the last heart finishes rising (used to hold the task's // exit animation until the hearts have almost finished). Computed from the // same formula the effect uses, so it's known before the effect starts. function risingHeartsRuntime() { const heartCount = Math.round(13 * celebrationAmountScale()); return Math.min(2200, (heartCount - 1) * 20 + 1500) + 300; } function createRisingHearts(targetElement) { try { const rect = targetElement.getBoundingClientRect(); const container = celebrationContainer(); const scale = celebrationAmountScale(); const heartScale = scale >= 4 ? 2.2 : scale >= 2 ? 1.5 : 1; const colors = ['#ff5c8a', '#ff80ab', '#f06292', '#ff4081', '#e91e63']; const heartCount = Math.round(13 * scale); for (let i = 0; i < heartCount; i++) { const el = document.createElement('span'); el.className = 'canvasrefined-heart'; const inner = document.createElement('span'); inner.className = 'canvasrefined-heart-inner'; inner.textContent = '\u2764'; el.appendChild(inner); const size = (10 + Math.random() * 14) * heartScale; el.style.fontSize = size + 'px'; inner.style.color = colors[Math.floor(Math.random() * colors.length)]; el.style.left = Math.round(rect.left + rect.width * 0.14 + Math.random() * rect.width * 0.72) + 'px'; el.style.top = Math.round(rect.top + rect.height - 8) + 'px'; inner.style.textShadow = '0 1px 4px rgba(0,0,0,.25)'; el.style.setProperty('--cr-hy', '-' + Math.round(90 + Math.random() * 150) + 'px'); el.style.setProperty('--cr-hx', (10 + Math.random() * 22).toFixed(1) + 'px'); el.style.setProperty('--cr-s', (0.8 + Math.random() * 0.7).toFixed(2)); el.style.setProperty('--cr-rot', Math.round(6 + Math.random() * 8) + 'deg'); el.style.setProperty('--cr-dur', (900 + Math.random() * 600) + 'ms'); // Lead the slide-away: hearts pop out instantly (fill-mode "both" // keeps them hidden during their delay) and stream up in tight // succession — the task is still on screen while they launch. el.style.setProperty('--cr-cdelay', Math.round(i * (i < 3 ? 16 : 20)) + 'ms'); container.appendChild(el); } // The container removal clears every heart; no per-heart timers needed. // Scale the timeout with the count so big amounts aren't cut off. setTimeout(() => { try { container.remove(); } catch (e) { /* ignore */ } }, 2400 + heartCount * 20); return risingHeartsRuntime(); } catch (e) { console.error('hearts error', e); } } // The rocket exit flies the task element itself off the page (via the // canvasrefined-rocket-exit keyframes applied by applyTodoExitAnimation); // this spawns the exhaust flame trail that follows the flying card. function createRocketCelebration(targetElement) { try { const container = celebrationContainer(); const colors = ['#ffb74d', '#ff9800', '#ff7043', '#ffd54f', '#eceff1', '#ffcc80']; const flightMs = 1200; const scale = celebrationAmountScale(); const start = performance.now(); const flames = []; // tracked so expiry is swept in the same pass // Pool cap scales with the amount tier so "crazy" never saturates and // starves the trail (which made the thruster visibly cut out early). const poolCap = Math.round(60 * Math.max(1, scale)); let rafId = null; const tick = (now) => { let r; try { r = targetElement.getBoundingClientRect(); } catch (e) { r = null; } // retire expired flames in the same pass (no per-flame timers) for (let i = flames.length - 1; i >= 0; i--) { if (now >= flames[i].expire) { flames[i].el.remove(); flames.splice(i, 1); } } const gone = !r || r.width === 0 || r.bottom < -110 || r.top > window.innerHeight + 110; if (!gone && now - start < flightMs + 150) { // Spawn every frame while the pool has room; flames live // ~0.5-1s so the trail stays continuous until the card is // fully offscreen. if (flames.length < poolCap) { const climb = Math.max(0.35, Math.min(1, (now - start) / 500)); // throttle ramps up after liftoff // Capped: extra tier budget goes into flame size/length, // not into an ever-growing per-frame spawn rate. const jets = Math.min(8, Math.max(2, Math.round((4 + Math.random() * 2) * Math.sqrt(scale)))); for (let i = 0; i < jets; i++) { // Amount tier drives thruster particle size and flame length. const size = (3 + Math.random() * 6) * scale; const life = (380 + Math.random() * 380) * (scale >= 4 ? 1.3 : 1); const el = document.createElement('div'); el.className = 'canvasrefined-rocket-flame'; el.style.width = (size * (0.8 + climb * 0.5)).toFixed(1) + 'px'; el.style.height = (size * (1.4 + Math.random() * 1.2) * (0.8 + climb * 0.5)).toFixed(1) + 'px'; el.style.left = Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)) + 'px'; el.style.top = Math.round(r.bottom - 4) + 'px'; el.style.background = colors[(Math.random() * colors.length) | 0]; // WAAPI: compositor-run motion; flame travel distance // scales with the amount tier so the exhaust keeps up // with bigger/faster rockets. const fx = (Math.random() - 0.5) * 28 * scale; const fy = 30 * scale; el.animate([ { transform: 'translate(0px, 0px) scale(1)', opacity: 0.95 }, { transform: 'translate(' + fx.toFixed(1) + 'px, ' + fy + 'px) scale(0.15)', opacity: 0 } ], { duration: Math.round(life), easing: 'ease-out', fill: 'forwards' }); container.appendChild(el); flames.push({ el, expire: now + life + 120 }); } } rafId = requestAnimationFrame(tick); } else { // Let the last flames finish, then drop the whole container. setTimeout(() => { try { container.remove(); } catch (e) { /* ignore */ } }, 1100); } }; rafId = requestAnimationFrame(tick); } catch (e) { console.error('rocket celebration error', e); } } // --- Celebration preview ----------------------------------------------------- // "Preview" (popup Celebrations box): runs the currently selected effect + // amount against a disposable fake task so the user can see what they'll get // without completing a real task. The fake card drops into the real todo list // when it's rendered here, or floats top-right as a fallback. function runCelebrationPreview() { try { if (enabledCelebrationTypes().length === 0) return { ok: false, reason: "disabled" }; // Clean up any preview that's still playing. const stale = document.getElementById("canvasrefined-celebration-preview"); if (stale) stale.remove(); // Drop the fake task into the "Due Today" group so the celebration // plays exactly where a real completion happens. The preview card // reuses the real task-row markup (colored strip, title, due date) // so its size/position matches an actual task. const todayContainer = domContainers["0"]; const listEl = document.getElementById("canvasrefined-todo-list"); const onTasksTab = betterTodoFilter === "tasks" && todayContainer && todayContainer.wrapper.isConnected && listEl; const card = document.createElement("div"); card.id = "canvasrefined-celebration-preview"; card.className = "better-todo-assignment"; card.style.overflowX = "hidden"; card.innerHTML = `
Celebration preview Sample task Due today
`; let restoreWrapper = null; if (onTasksTab) { const groupList = todayContainer.wrapper.querySelector(".todo-group-list"); if (groupList) groupList.insertBefore(card, groupList.firstChild); // Force the Due Today group visible for the preview, but remember // its previous state: when the group has no real items it starts // hidden, and without restoring it the empty "Due Today" header // would stay behind after the sample card leaves. restoreWrapper = { wrapper: todayContainer.wrapper, display: todayContainer.wrapper.style.display, hadVisible: todayContainer.wrapper.hasAttribute("data-has-visible"), }; todayContainer.wrapper.style.display = "block"; todayContainer.wrapper.setAttribute("data-has-visible", "true"); } else { // Tasks tab not rendered here (other filter, other page) — float // the sample card top-right so a preview is still possible. card.style.cssText += "position:fixed;top:70px;right:16px;width:300px;z-index:2147483646;box-shadow:0 2px 10px rgba(0,0,0,.25);"; document.body.appendChild(card); } // Same flow as a real completion: pick a random enabled effect with // the current amount, then apply the matching exit animation. The // effect start honours the lead-in pause and is skipped if the sample // card is gone by then (e.g. an immediate re-preview). const celebration = runCelebration(card); if (celebration.start) { const startFn = celebration.start; setTimeout(() => { if (card.isConnected) startFn(); }, celebration.startDelay || 0); } applyTodoExitAnimation(card, celebration.style, celebration.delay); setTimeout(() => { try { card.remove(); } catch (e) { /* ignore */ } // Put the Due Today group back the way we found it. if (restoreWrapper) { try { restoreWrapper.wrapper.style.display = restoreWrapper.display; if (!restoreWrapper.hadVisible) restoreWrapper.wrapper.removeAttribute("data-has-visible"); } catch (e) { /* wrapper re-rendered away already */ } } }, celebrationExitDuration(celebration.style, celebration.delay) + 120); return { ok: true }; } catch (e) { console.error("celebration preview error", e); return { ok: false, reason: "error" }; } } // --- 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; // Pre-click state, captured so the optimistic UI can be rolled back if // the server definitively refuses the change (see revertOptimisticUI). const previousComplete = item.planner_override ? item.planner_override.marked_complete === true : false; const hadPin = isPinnedIncomplete(item); // 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; // A previous markAs on this same element may still have celebration or // exit timers pending (fast double-click). Cancel them so the new action // starts clean instead of racing the old one's scheduled effect/exit. element.__crCelebrationCancelled = true; if (element.__crExitTimer) { clearTimeout(element.__crExitTimer); element.__crExitTimer = null; } if (element.__crCelebTimer) { clearTimeout(element.__crCelebTimer); element.__crCelebTimer = null; } element.__crCelebrationCancelled = false; // Celebrate only when marking complete (not when unmarking). // Must happen BEFORE the exit animation below: the effects measure the // element with getBoundingClientRect(), which reflects transforms — // measuring after translate(100%) would place the origin one full card // width to the right (off screen) whenever the transform applies // instantly, i.e. on cards without a transition (announcements). let exitStyle = "slide"; let exitDelay = 0; if (completeState) { try { const celebration = runCelebration(element); exitStyle = celebration.style; exitDelay = celebration.delay || 0; // Overlay effects (confetti/fireworks/sparkles/hearts) fire after // the lead-in pause; the exit is scheduled separately. Both are // cancelled if the optimistic UI gets reverted meanwhile. if (celebration.start) { const startFn = celebration.start; const fire = () => { if (element.__crCelebrationCancelled === true || !element.isConnected) return; startFn(); }; if (celebration.startDelay > 0) { if (element.__crCelebTimer) clearTimeout(element.__crCelebTimer); element.__crCelebTimer = setTimeout(() => { element.__crCelebTimer = null; fire(); }, celebration.startDelay); } else { fire(); } } } catch (e) { console.error('celebration trigger error', e); } } applyTodoExitAnimation(element, exitStyle, exitDelay); // update progress rings immediately so they animate while the item slides/fades const refreshProgressRings = () => { const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); if (!progressPlaceholder || typeof assignments?.then !== 'function' || !progressRingsEnabled()) return; 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; } } // Keep the rings consistent with the list when fake items are shown. if (options.hide_personal_details === true && options.anon_fake_todo === true) { scopedData.push(...buildFakeTodoItems()); } renderProgressRings(progressPlaceholder, scopedData); }); }; refreshProgressRings(); // --- Failure rollback --- // Restores the pre-click state when the server definitively refuses the // toggle: a POST 400 whose existing override can't be recovered, a failed // PUT, or a network error. Canvas's planner_override uniqueness scope is // (user, plannable_type, plannable_id) regardless of workflow_state, but // soft-deleted override rows are invisible to BOTH read endpoints, so a // dormant row can permanently block the POST with 400 "already exists". // Without a rollback the optimistic UI would silently lie until the next // reload, when the item reappears unchecked — the reported "cannot check // off / won't move to Completed" bug. const revertOptimisticUI = (reason) => { item.planner_override = item.planner_override || {}; item.planner_override.marked_complete = previousComplete; // undo the pin side effects of this toggle if (completeState) { if (hadPin) setPinnedIncomplete(item, true); } else if (item.submissions?.submitted && !hadPin) { setPinnedIncomplete(item, false); } console.warn("planner override not persisted, reverting UI:", reason); // snap the card back if it is still in the DOM (before the swap); // also undo any celebration exit animation applied to it element.style.transform = ""; element.style.opacity = ""; element.style.transition = ""; element.style.visibility = ""; element.classList.remove("canvasrefined-rocket-exit"); element.__crCelebrationCancelled = true; if (element.__crExitTimer) { clearTimeout(element.__crExitTimer); element.__crExitTimer = null; } if (element.__crCelebTimer) { clearTimeout(element.__crCelebTimer); element.__crCelebTimer = null; } if (element.__crRocketClone) { try { element.__crRocketClone.remove(); } catch (e) { /* ignore */ } element.__crRocketClone = null; } const loc = document.querySelector("#canvasrefined-todo-list"); if (loc) { clearTodoList(); createTodoSections(loc); } refreshProgressRings(); }; setTimeout(() => { clearTodoList(); createTodoSections(document.querySelector("#canvasrefined-todo-list")); }, celebrationExitDuration(exitStyle, exitDelay)); // --- 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. If every attempt fails, the // optimistic UI is rolled back so the card visibly returns instead of // silently dropping the check-off on the next reload. 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 }) }); // Loads every planner override the user has. The index endpoint is // paginated at 10 items per page and only ever returns ACTIVE overrides — // Canvas soft-deletes overrides (workflow_state="deleted") instead of // removing the row, and those dormant rows still satisfy the // (user, plannable_type, plannable_id) uniqueness constraint. A dormant // row therefore makes POST create fail with 400 "already exists" while // being unlistable, which is why items in some courses could never be // checked off: the old recovery read a single un-paginated page, missed // the row, and never PUT. Page through with per_page=100 following the // Link header (same pattern as the planner items fetch). Returns null on // any failure so the caller can distinguish "no match" from "unknown". const fetchAllOverrides = async () => { const all = []; let url = domain + "/api/v1/planner/overrides?per_page=100"; for (let page = 0; page < 10 && url; page++) { const pageResp = await fetch(url, { headers: { "accept": "application/json" } }); if (!pageResp.ok) return null; const data = await pageResp.json(); if (!Array.isArray(data)) return null; all.push(...data); url = getNextPageUrl(pageResp.headers.get("Link")); } return all; }; // A stored override may not carry the identity the planner items feed // gave us: Canvas re-points overrides on save (discussion overrides move // to the root topic id, assignment overrides can be re-typed to // quiz/discussion_topic/wiki_page with a different id), so also match on // the associated assignment_id as a fallback. const matchesItem = (o) => { if (!o) return false; if (String(o.plannable_id) === String(item.plannable_id) && String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase()) return true; const assignmentId = item.plannable && item.plannable.assignment_id != null ? item.plannable.assignment_id : (item.plannable_type === "assignment" ? item.plannable_id : null); return assignmentId != null && o.assignment_id != null && String(o.assignment_id) === String(assignmentId); }; 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, or a dormant soft-deleted row blocks it). // 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 overrides = await fetchAllOverrides(); const match = overrides && overrides.find(matchesItem); 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) { try { const saved = await retry.json(); if (saved && saved.id) item.planner_override.id = saved.id; } catch (e) { /* id stays unknown */ } console.log("marked as complete (recovered override id)"); return; } console.warn("planner override PUT retry returned", retry.status); return revertOptimisticUI("PUT retry returned " + retry.status); } // No matching override (or the listing failed): nothing to // PUT against, so the change was never persisted. return revertOptimisticUI("POST returned " + resp.status + " and no matching override was found"); } catch (e) { console.error("error recovering planner override id", e); return revertOptimisticUI("override recovery failed: " + e); } } // A PUT on a known id failed (e.g. the override was removed // server-side between our listing and the toggle): roll back. return revertOptimisticUI("request returned " + resp.status); }) .catch(err => { console.error("error marking as complete", err); revertOptimisticUI(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; // Only the main dashboard and course homepages get the todo sidebar; // sub-pages (assignments, modules, files, ...) keep Canvas's own sidebar. if (!isTodoAllowedPage()) return; if (options.better_todo !== true || isGradesPage()) return; if (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`); } // --- Mobile global-nav tray (Better Sidebar styling) ------------------------ // Below Canvas' responsive breakpoint the global nav collapses into an InstUI // tray dialog ([role="dialog"][aria-label="Global Navigation"]) instead of the // .ic-app-header rail, so none of the rail's theming reaches it. When Better // Sidebar is enabled, give that dialog the same look as the rail: --bcsidebar // surface at the sidebar opacity/blur slider values, --bcsidebar-text ink, and // rounded sidebar-style rows. The dialog's behavior, item list and a11y // attributes are left untouched — styling only. Selectors use stable // attributes ([role], [aria-label], [data-cid]) instead of emotion hashes, // which shift between Canvas releases; an unmatched rule is simply inert. function setupMobileNavStyle() { let style = document.getElementById("canvasrefined-mobile-nav") || document.createElement("style"); style.id = "canvasrefined-mobile-nav"; if (!options.better_sidebar) { style.remove(); return; } const sidebarOpacity = Math.max(0, Math.min(100, Number(options.sidebar_opacity ?? 100))); const sidebarBlur = Math.max(0, Math.min(30, Number(options.sidebar_blur ?? 0))); const sidebarTransparent = 100 - sidebarOpacity; const dialog = '[role="dialog"][aria-label="Global Navigation"]'; style.textContent = ` /* Tray wrapper: consistent drawer width instead of InstUI's inline width, so labels have room on any narrow window. */ span:has(> ${dialog}) { width: min(320px, 90vw) !important; max-width: 90vw !important; } /* Panel: the same glass surface as the Better Sidebar rail */ ${dialog} { background: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important; backdrop-filter: blur(${sidebarBlur}px) !important; -webkit-backdrop-filter: blur(${sidebarBlur}px) !important; border-right: 1px solid color-mix(in srgb, var(--bcsidebar-text) 10%, transparent) !important; border-radius: 0 16px 16px 0 !important; box-shadow: 8px 0 40px rgba(0, 0, 0, .28) !important; color: var(--bcsidebar-text) !important; } /* Ink: recolor labels, icons and expand arrows to the sidebar text color. Tray icons are fill-based InstUI SVGIcons, so fill covers them; stroke-based icons inherit currentColor from the dialog. Canvas ships some tray text with color="brand" (link-blue), so cover every text/emotion View span too, not just . */ ${dialog} svg { fill: var(--bcsidebar-text) !important; } ${dialog} a, ${dialog} span[class*="-text"], ${dialog} h2[class*="-heading"], ${dialog} h2[class*="-heading"] a { color: var(--bcsidebar-text) !important; text-decoration: none !important; } /* The tray header's big institution logo link (Canvas renders it as a large image or wordmark). Hide it so the header collapses to just the close button, matching the rail's compact top. */ ${dialog} .ic-brand-mobile-global-nav-logo { display: none !important; } /* Rows: padded, rounded, same hover tint as the rail's links */ ${dialog} li[data-cid="ListItem"] > a, ${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button { padding: 12px 14px !important; border-radius: 10px !important; transition: background-color .15s ease !important; } ${dialog} li[data-cid="ListItem"] > a:hover, ${dialog} li[data-cid="ListItem"] > a:focus-visible, ${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button:hover, ${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button:focus-visible { background: #0000004f !important; } /* Expanded sub-items (Account/Courses/History/Help details), indented under their parent like the rail's expanded section links */ ${dialog} div[id^="Expandable"] a { padding: 10px 14px 10px 40px !important; border-radius: 10px !important; opacity: .92; transition: background-color .15s ease !important; } ${dialog} div[id^="Expandable"] a:hover, ${dialog} div[id^="Expandable"] a:focus-visible { background: #0000004f !important; } /* Close button: bare glass circle (data-cid holds multiple tokens, so match on substring) */ ${dialog} button[data-cid*="BaseButton"] { background: color-mix(in srgb, var(--bcsidebar-text) 12%, transparent) !important; border-radius: 999px !important; color: var(--bcsidebar-text) !important; transition: background-color .15s ease !important; } ${dialog} button[data-cid*="BaseButton"]:hover { background: color-mix(in srgb, var(--bcsidebar-text) 22%, transparent) !important; } /* Avatar: subtle ring so it reads on the glass surface */ ${dialog} [class*="-avatar"] { box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcsidebar-text) 30%, transparent) !important; } /* Thin, theme-tinted scrollbar for the tray content */ ${dialog} ::-webkit-scrollbar { width: 8px; height: 8px; } ${dialog} ::-webkit-scrollbar-track { background: transparent; } ${dialog} ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--bcsidebar-text) 30%, transparent); border-radius: 999px; } `; // The mobile course-context nav (#mobileContextNavContainer — the // Home/Assignments/Grades list behind the arrow button) is the narrow- // window counterpart of the desktop nav-toggle + breadcrumbs bar, which // Better Sidebar already hides under the "Hide Navigation Bar" sub-option. // Apply the same gate here: hide the nav and its .mobile-header-arrow // button only when Better Sidebar is on AND "Hide Navigation Bar" is on. if (options.hide_navbar === true) { style.textContent += ` #mobileContextNavContainer, button.mobile-header-arrow { display: none !important; } `; } if (!style.isConnected) document.documentElement.append(style); } // 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(" label.style.display = expanded ? "block" : "none"); const buttons = document.querySelectorAll(".better-sidebar-btn"); buttons.forEach(label => label.style.width = expanded ? "80%" : "40%"); sidebarList.querySelectorAll(".better-sidebar-btn svg").forEach(svg => { svg.style.width = "var(--bc-sidebar-icon-size,20px)"; svg.style.height = "var(--bc-sidebar-icon-size,20px)"; }); // Expand (or restore) the entire left-side column when the sidebar toggles const leftSide = document.getElementById("left-side"); if (leftSide) { // on first run store the original width (prefer computed) and inline flex/maxWidth if (!leftSide.dataset.bcOrigWidth) { const computed = getComputedStyle(leftSide).width || ""; leftSide.dataset.bcOrigWidth = leftSide.style.width || ""; leftSide.dataset.bcOrigFlex = leftSide.style.flex || ""; leftSide.dataset.bcOrigMaxWidth = leftSide.style.maxWidth || ""; leftSide.dataset.bcOrigWidthPx = parseFloat(computed) || 0; } const origPx = parseFloat(leftSide.dataset.bcOrigWidthPx || 0); const delta = expandedWidth - collapsedWidth; if (expanded) { if (origPx > 0) { const newWidth = Math.round(origPx + delta); leftSide.style.flex = `0 0 ${newWidth}px`; leftSide.style.width = `${newWidth}px`; leftSide.style.maxWidth = `${newWidth}px`; } else { leftSide.style.flex = `0 0 ${expandedWidth}px`; leftSide.style.width = `${expandedWidth}px`; leftSide.style.maxWidth = `${expandedWidth}px`; } } else { // restore original inline values if present, otherwise remove the properties if (leftSide.dataset.bcOrigWidth !== "") leftSide.style.width = leftSide.dataset.bcOrigWidth; else leftSide.style.removeProperty('width'); if (leftSide.dataset.bcOrigFlex !== "") leftSide.style.flex = leftSide.dataset.bcOrigFlex; else leftSide.style.removeProperty('flex'); if (leftSide.dataset.bcOrigMaxWidth !== "") leftSide.style.maxWidth = leftSide.dataset.bcOrigMaxWidth; else leftSide.style.removeProperty('max-width'); } } const courseLinksTitle = document.getElementById("better-course-links-title"); if (courseLinksTitle) { courseLinksTitle.style.display = expanded ? "block" : "none"; // Also hide separator when collapsed const separator = courseLinksTitle.nextElementSibling; if (separator) separator.style.display = expanded ? "block" : "none"; const container = document.getElementById("better-course-links"); if (container) { container.style.opacity = expanded ? "1" : "0.6"; container.style.gap = expanded ? "12px" : "8px"; } } } let delay; let moreAssignmentCount = 0; let moreAnnouncementCount = 0; let filter = "todo"; async function loadBetterTodo() { if (options.better_todo !== true || isGradesPage()) return; try { await getColors(); const discussion_svg = ''; const quiz_svg = ''; const announcement_svg = ''; const assignment_svg = ''; const x_svg = ''; const check_svg = ''; const tag_svg = ''; // end of SVGs const maxAssignmentCount = parseInt(options.num_todo_items) + moreAssignmentCount; const maxAnnouncementCount = parseInt(options.num_todo_items) + moreAnnouncementCount; const hr24 = options.todo_hr24; const now = new Date(); //const csrfToken = CSRFtoken(); let todoAnnouncements = document.querySelector("#canvasrefined-announcement-list"); let todoAssignments = document.querySelector("#canvasrefined-todo-list"); let assignmentsToInsert = []; let announcementsToInsert = []; assignments.then(data => { chrome.storage.sync.get(options.custom_assignments_overflow, storage => { //assignmentData = assignmentData === null ? data : assignmentData; let items = combineAssignments(data); // Same "fill the todo list with fake courses" behavior as the // modern renderer (see createTodoSections). Re-sorted so the // fake items slot into chronological order like real ones. if (options.hide_personal_details === true && options.anon_fake_todo === true) { items = items.concat(buildFakeTodoItems()) .sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date)); } items.forEach((item, index) => { let date = new Date(item.plannable_date); let itemState = options.assignment_states[item.plannable_id]; let svg; switch (item.plannable_type) { case "assignment": svg = assignment_svg; break; case "discussion_topic": svg = discussion_svg; break; case "quiz": svg = quiz_svg; break; case "announcement": svg = announcement_svg; break; default: return; } // if (item.plannable_type === "announcement") { //if (announcementsToInsert.length >= maxAnnouncementCount + 1) return; if (item.plannable_type !== "announcement") { // leaving one extra assignment in the array to indicate there are more and the "view more" button should be created if (assignmentsToInsert.length >= maxAssignmentCount + 1) return; if (filter === "todo" && options.hide_completed === true && item.submissions.submitted === true) return; if (filter === "todo" && ((options.todo_overdues !== true && now >= date) || (options.todo_overdues === true && item.submissions.submitted === true))) return; if (filter === "done" && now <= date && !(itemState?.["rem"] === true || item?.submissions?.submitted === true)) return; //if (item.plannable_type !== "assignment" && item.plannable_type !== "quiz" && item.plannable_type !== "discussion_topic") return; } if (filter === "todo" && ((itemState && itemState["rem"] === true) || (item.planner_override && item.planner_override.marked_complete === true))) return; let listItemContainer = document.createElement("div"); listItemContainer.classList.add("canvasrefined-todo-container"); listItemContainer.innerHTML = '

'; listItemContainer.querySelector(".canvasrefined-todo-item").href = item.html_url; listItemContainer.dataset.id = item.plannable_id; listItemContainer.querySelector('.canvasrefined-todo-icon').innerHTML += svg; let listItem = listItemContainer.querySelector(".canvasrefined-todo-item"); const courseColor = item.anonFake === true ? item.anonColor // fake items carry their (real) course color : (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"); if (itemState?.["lbl"] && itemState["lbl"] !== "") { makeElement("span", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todo-label", "textContent": itemState["lbl"] }); } if (itemState?.["crs"] === true) { listItemContainer.querySelector(".canvasrefined-todo-item").style.textDecoration = "line-through"; } let title = makeElement("a", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todoitem-title", "textContent": anonTitleForItem(item) }); if (options.todo_hide_feedback === true) title.style = "color:" + courseColor + "!important;"; let course = makeElement("p", listItem, { "className": "canvasrefined-todoitem-course", "textContent": anonCourseNameForItem(item) }); course.style.color = courseColor; let format = formatTodoDate(date, item.submissions, hr24); let todoDate = makeElement("p", listItem, { "className": "canvasrefined-todoitem-date", "textContent": format.date }); if (format.dueSoon) todoDate.classList.add("canvasrefined-due-soon"); if (options.hover_preview === true) { const customItem = item.planner_override && item.planner_override.custom && item.planner_override.custom === true; listItem.addEventListener("mouseover", () => { listItem.classList.add("canvasrefined-todo-hover"); let preview = listItemContainer.querySelector(".canvasrefined-hover-preview"); let previewTitle = preview.querySelector(".canvasrefined-preview-title"); let previewText = preview.querySelector(".canvasrefined-preview-text"); clearTimeout(delay); delay = setTimeout(async () => { if (listItem.classList.contains("canvasrefined-todo-hover")) { previewTitle.textContent = item.plannable.title; // Fake todo items have no Canvas page behind them. if (item.anonFake === true) { previewText.textContent = "No details given"; preview.style.display = "block"; return; } // custom assignment (planner note): preview its description/details if (customItem) { const details = item.plannable && item.plannable.details ? item.plannable.details : ""; previewText.textContent = details === "" ? "No details given" : details.replace(/<\/?[^>]+(>|$)/g, " "); } else { console.log(item); let found = false; let searchCount = 1; while (searchCount < 5 && found === false) { for (let i = 0; i < announcements.length; i++) { if (announcements[i].id === item.plannable_id) { found = true; if (previewText.textContent === "") { let description = item.plannable_type === "announcement" ? announcements[i].message : announcements[i].description; previewText.textContent = description === "" ? "No details given" : description.replace(/<\/?[^>]+(>|$)/g, " "); } break; } } if (found === false) { let apiLink = domain + "/api/v1/"; if (item.plannable_type === "assignment") { apiLink += `courses/${item.course_id}/assignments/${item.plannable_id}`; } else if (item.plannable_type === "announcement") { apiLink += `announcements?context_codes[]=course_${item.course_id}&per_page=3&page=${searchCount}`; } let data = await getData(apiLink); item.plannable_type === "announcement" ? announcements.push(...data) : announcements.push(data); searchCount++; } } if (found === false) { previewText.textContent = "Couldn't load preview"; } } preview.style.display = "block"; } }, 250); }); listItem.addEventListener("mouseleave", () => { listItem.classList.remove("canvasrefined-todo-hover"); listItemContainer.querySelector(".canvasrefined-hover-preview").style.display = "none"; }); } const actions = listItemContainer.querySelector(".canvasrefined-todo-actions"); let clickOutActions = (e) => { if (e.target.className.includes("canvasrefined")) return; document.body.removeEventListener("click", clickOutActions); actions.style.display = "none"; } listItemContainer.querySelector(".canvasrefined-todo-actions-btn").addEventListener("click", () => { actions.style.display = "block"; setTimeout(() => { document.body.addEventListener("click", clickOutActions); }, 100); }); let removeBtn = makeElement("div", actions, { "className": "canvasrefined-todo-action", "textContent": "Remove" }); removeBtn.innerHTML += x_svg; const dueAt = new Date(item.plannable_date).getTime(); let crossOffBtn = makeElement("div", actions, { "className": "canvasrefined-todo-action", "textContent": "Cross off" }); crossOffBtn.innerHTML += check_svg; crossOffBtn.addEventListener("click", () => { setAssignmentState(item.plannable_id, { "crs": listItemContainer.querySelector(".canvasrefined-todo-item").style.textDecoration === "line-through" ? false : true, "expire": dueAt }); }); let label = makeElement("span", actions, { "className": "canvasrefined-todo-action-tag", "textContent": "Label:" }); label.innerHTML += tag_svg; let labelInput = makeElement("input", actions, { "className": "canvasrefined-todo-input", "type": "text", "placeholder": "Label", "value": itemState && itemState["lbl"] ? itemState["lbl"] : "" }); labelInput.addEventListener("change", (e) => { setAssignmentState(item.plannable_id, { "lbl": e.target.value, "expire": dueAt }); }); removeBtn.addEventListener('click', function () { setAssignmentState(item.plannable_id, { "rem": filter === "todo", "expire": dueAt }); if (item.planner_override && item.planner_override.custom && item.planner_override.custom === true) { // set item as complete locally chrome.storage.sync.get("custom_assignments_overflow", overflow => { chrome.storage.sync.get(overflow["custom_assignments_overflow"], storage => { overflow["custom_assignments_overflow"].forEach(overflow => { for (let i = 0; i < storage[overflow].length; i++) { if (storage[overflow][i].plannable_id === item.plannable_id) { storage[overflow].splice(i, 1); chrome.storage.sync.set({ [overflow]: storage[overflow] }).then(() => { }); break; } } }); }); }); } }); if (item.plannable_type === "announcement") { announcementsToInsert.push(listItemContainer); } else { assignmentsToInsert.push(listItemContainer); if (item.submissions && item.submissions.submitted) { listItemContainer.classList.add("canvasrefined-todo-item-completed"); } } }); // appending assignments all at once todoAssignments.textContent = ""; if (assignmentsToInsert.length > 0) { let i; for (i = 0; i < (assignmentsToInsert.length > maxAssignmentCount ? maxAssignmentCount : assignmentsToInsert.length); i++) { todoAssignments.append(assignmentsToInsert[i]); } if (i !== assignmentsToInsert.length) createTodoViewMore(todoAssignments, "assignment"); } else { makeElement("p", todoAssignments, { "className": "canvasrefined-none-due", "textContent": "None" }); } // appending announcements all at once todoAnnouncements.textContent = ""; if (announcementsToInsert.length > 0) { let i; for (i = announcementsToInsert.length - 1; i >= (announcementsToInsert.length - maxAnnouncementCount < 0 ? 0 : announcementsToInsert.length - maxAnnouncementCount); i--) { todoAnnouncements.append(announcementsToInsert[i]); } if (i !== -1) createTodoViewMore(todoAnnouncements, "announcement"); } else { makeElement("p", todoAnnouncements, { "className": "canvasrefined-none-due", "textContent": "None" }); } cleanCustomAssignments(); }); }); } catch (e) { logError(e); } } /* Card color palettes */ let changeColorInterval = null; let colorChanges = []; // Course list for palette operations, in DISPLAY order (first shown to // last) so palette colors land on courses in the order the user sees them. // Card view: dashboard cards are already in the DOM in display order. // List mode: there are no .ic-DashboardCard elements (which used to make the // palette silently do nothing), so fall back to the dashboard_cards API — // ordered by where each course's planner grouping first appears top-to- // bottom, with any courses not currently displayed (no items in the loaded // date range) at the end in API order. Also returns the user's current // course colors from the users/self/colors API (used for "revert colors" // when no DOM cards exist to read inline styles from). async function getPaletteCards() { let cards = []; let apiColors = {}; document.querySelectorAll(".ic-DashboardCard__header").forEach(card => { cards.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "el": card }); }); if (cards.length > 0) return { cards, apiColors }; try { const [cardsRes, colorsRes] = await Promise.all([ fetch(domain + "/api/v1/dashboard/dashboard_cards", { headers: { "accept": "application/json" } }), fetch(domain + "/api/v1/users/self/colors", { headers: { "accept": "application/json" } }) ]); const apiCards = await cardsRes.json(); apiColors = (await colorsRes.json())?.custom_colors || {}; const seen = new Set(); const orderedIds = []; document.querySelectorAll("a.Grouping-styles__hero").forEach(hero => { const m = (hero.getAttribute("href") || "").match(/\/courses\/(\d+)/); if (m && !seen.has(m[1])) { seen.add(m[1]); orderedIds.push(m[1]); } }); apiCards.forEach(card => { const id = String(card.id); if (!seen.has(id)) { seen.add(id); orderedIds.push(id); } }); orderedIds.forEach(id => cards.push({ "href": domain + "/courses/" + id, "el": null })); } catch (e) { logError(e); } return { cards, apiColors }; } async function changeColorPreset(colors) { if (colors.length === 0) return; // reset everything //let res = await getData(`${domain}/api/v1/users/self/colors`); clearInterval(changeColorInterval); const csrfToken = CSRFtoken(); const delay = 250; previous = [] colorChanges = []; // sort cards // (display order — see getPaletteCards; no re-sorting here so palette // colors apply from the first course on screen to the last) const { cards: sortedCards, apiColors } = await getPaletteCards(); // push each color change into a queue try { sortedCards.forEach((card, i) => { let course_id = card.href.split("courses/")[1]; let previousColor = card.el ? rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor) : (apiColors["course_" + course_id] || "#ffffff"); previous.push(previousColor); let cnum = i % colors.length; // Apply the new color to whatever surface is rendered: dashboard // card elements (card view) or planner item avatars (list view), // so the change is visible immediately instead of only after a // reload. let applyColor = () => { if (card.el) { card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum]; card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum]; card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum]; // Recompute this card's gradient from its NEW color right // away — changeGradientCards() outside the queue ran too // early (colors land later, on the 250ms interval), which // left gradients built from the previous course colors. changeGradientCards(); } else { const coursePrefix = "/courses/" + course_id; document.querySelectorAll(".planner-item").forEach(item => { const titleLink = item.querySelector(".PlannerItem-styles__title a"); const heroLink = item.closest(".Grouping-styles__root")?.querySelector("a.Grouping-styles__hero"); const inCourse = (titleLink && (titleLink.getAttribute("href") || "").startsWith(coursePrefix)) || (heroLink && (heroLink.getAttribute("href") || "").startsWith(coursePrefix)); if (inCourse) { const avatar = item.querySelector(".PlannerItem-styles__avatar, .PlannerItem-styles__icon"); if (avatar) avatar.style.color = colors[cnum]; } }); } }; let changeCardColor = () => { fetch(domain + "/api/v1/users/self/colors/courses_" + course_id, { method: "PUT", headers: { "content-type": "application/json", 'accept': 'application/json', 'X-CSRF-Token': csrfToken, }, body: JSON.stringify({ "hexcode": colors[cnum] }) }).then(() => applyColor()); } colorChanges.push(changeCardColor); applyColor(); }); } catch (e) { logError(e); colorChanges = []; } changeGradientCards(); // go through the queue until empty changeColorInterval = setInterval(() => { if (colorChanges.length > 0) { let current = colorChanges.shift(); current(); } else { clearInterval(changeColorInterval); } }, delay); // set colors to revert back to chrome.storage.local.get("previous_colors", local => { const now = Date.now(); const prev = local["previous_colors"]; // Overwrite when missing or expired — and when an old list-mode run // (which found no dashboard cards) stored an empty list, which made // revert a silent no-op. Never store an empty capture (nothing to // revert to). chrome.storage.local.get yields undefined (not null) // for an unset key, so the old `=== null` check never matched it. if (previous.length > 0 && (!prev || now >= prev.expire || !Array.isArray(prev.colors) || prev.colors.length === 0)) { chrome.storage.local.set({ "previous_colors": { "colors": previous, "expire": now + 86400000 } }); } }); } /* Dark mode */ // Light-mode fallbacks for the --bc* variables, always emitted so extension UI renders in light mode; dark mode overrides below. const BC_LIGHT_DEFAULTS = { "background-0": "#ffffff", "background-1": "#c7c7c7", "background-2": "#d9d9d9", "borders": "#808080", "links": "#418df1", "sidebar": "#e3e3e3", "sidebar-text": "#000000", "text-0": "#000000", "text-1": "#050505", "text-2": "#4f4f4f" }; function generateDarkModeCSS() { // Always-on light-mode defaults so var(--bc*) resolves in light mode too. let css = ":root{\n"; Object.keys(BC_LIGHT_DEFAULTS).forEach((key) => { css += " --bc" + key + ": " + BC_LIGHT_DEFAULTS[key] + ";\n"; }); css += "}\n\n"; const darkOn = options.dark_mode === true || options.device_dark === true; if (!darkOn) return css; let darkBlock = ":root{\n"; if (options.dark_preset) { Object.keys(options.dark_preset).forEach((key) => { darkBlock += " --bc" + key + ": " + options.dark_preset[key] + ";\n"; }); } darkBlock += "}\n\n"; darkBlock += DARKMODE_CSS; if (options.device_dark === true) { css += "@media (prefers-color-scheme: dark) {\n" + darkBlock + "\n}"; } else { css += darkBlock; } return css; } let darkStyleInserted = false; // --- Submission tray dark-mode fixer --------------------------------------- // The comment chip on the grades page (…/grades) opens an InstUI // "Submission Comments Tray". Its "Attempt N Feedback" card, close-button // glyph, and some labels arrive with Canvas's light surface and dark ink // through emotion classes whose hashes change between Canvas releases, so // they can't be themed from the static dark stylesheet. Instead, watch for // the tray and recolor at runtime, scoped strictly to elements inside it. // Touched elements are tagged (data-crdarkfix) so turning dark mode off can // restore the original colors. let submissionTrayObserver = null; let submissionTrayFixScheduled = false; function crApplySubmissionTrayFix(el, props) { const applied = el.dataset.crdarkfix ? el.dataset.crdarkfix.split(" ") : []; for (const prop of Object.keys(props)) { if (!applied.includes(prop)) applied.push(prop); el.style.setProperty(prop, props[prop], "important"); } el.dataset.crdarkfix = applied.join(" "); } function fixSubmissionTrayColors(root) { const preset = options.dark_preset; if (!preset) return; const light = (r, g, b) => r >= 225 && g >= 225 && b >= 225; const darkInk = (r, g, b) => r <= 70 && g <= 70 && b <= 70; const rgb = (str) => { const m = str && str.match(/rgb\((\d+), ?(\d+), ?(\d+)/); return m ? [+m[1], +m[2], +m[3]] : null; }; root.querySelectorAll("*").forEach(el => { if (el instanceof HTMLImageElement || el instanceof HTMLVideoElement) return; const cs = getComputedStyle(el); const bg = rgb(cs.backgroundColor); if (bg && light(bg[0], bg[1], bg[2])) { // light card/surface -> theme surface with readable text crApplySubmissionTrayFix(el, { "background-color": preset["background-2"], "color": preset["text-0"] }); } else { const c = rgb(cs.color); if (c && darkInk(c[0], c[1], c[2])) { // dark ink on dark tray (headings, icon glyphs) crApplySubmissionTrayFix(el, { "color": preset["text-0"] }); } } const bc = rgb(cs.borderColor); if (bc && light(bc[0], bc[1], bc[2])) { crApplySubmissionTrayFix(el, { "border-color": preset["borders"] }); } }); } function setupSubmissionTrayWatcher() { if (submissionTrayObserver) return; const run = () => { submissionTrayFixScheduled = false; if (options.dark_mode !== true && options.device_dark !== true) return; const tray = document.querySelector('[data-testid="submission-tray"]'); if (tray) fixSubmissionTrayColors(tray); }; // Canvas mutates the DOM constantly; debounce to one run per frame (same // pattern as the footer observer in startExtension). Comments load into // the tray asynchronously, so re-run whenever the tray subtree changes. submissionTrayObserver = new MutationObserver(() => { if (submissionTrayFixScheduled) return; submissionTrayFixScheduled = true; requestAnimationFrame(run); }); submissionTrayObserver.observe(document.documentElement, { childList: true, subtree: true }); run(); } function teardownSubmissionTrayFixes() { if (submissionTrayObserver) { submissionTrayObserver.disconnect(); submissionTrayObserver = null; } document.querySelectorAll("[data-crdarkfix]").forEach(el => { (el.dataset.crdarkfix || "").split(" ").forEach(prop => el.style.removeProperty(prop)); delete el.dataset.crdarkfix; }); } function toggleDarkMode() { const css = generateDarkModeCSS(); const darkOn = options.dark_mode === true || options.device_dark === true; // Reuse the existing #darkcss style if present (never create a duplicate), so a // document_start dark-mode bootstrap and later updates stay on one element. let style = document.querySelector("#darkcss"); if (!style) { style = document.createElement('style'); style.id = 'darkcss'; document.documentElement.append(style); } style.textContent = css; style.className = darkOn ? "canvasrefined-darkmode-enabled" : ""; darkStyleInserted = true; // The InstUI submission tray is themed at runtime (emotion class hashes // change between Canvas releases); keep the watcher in sync with the // current mode, and undo inline fixes when dark mode turns off. if (darkOn) { setupSubmissionTrayWatcher(); } else { teardownSubmissionTrayFixes(); } runiframeChecker(); } function runDarkModeFixer(override = false) { // Quiz safe mode: never auto-run the dark mode fixer on quiz pages. if (quizSafeModeActive()) return { "path": "canvasrefined-none", "time": "" }; if (options.dark_mode !== true) return { "path": "canvasrefined-darkmode_off", "time": "" }; if (override === false && !options["dark_mode_fix"].includes(window.location.pathname)) return { "path": "canvasrefined-none", "time": "" }; let output = inspectDarkMode(); return { "path": window.location.pathname, "time": output.time }; } function autoDarkModeCheck() { let date = new Date(); let currentHour = date.getHours(); let currentMinute = date.getMinutes(); let status = false; if (options.auto_dark === false) return; let startHour = parseInt(options.auto_dark_start["hour"]); let startMinute = parseInt(options.auto_dark_start["minute"]); let endHour = parseInt(options.auto_dark_end["hour"]); let endMinute = parseInt(options.auto_dark_end["minute"]); if (currentHour === startHour) { status = currentMinute >= startMinute; } else if (currentHour === endHour) { status = currentMinute <= endMinute; } else if (startHour > endHour) { status = currentHour > startHour || currentHour < endHour; } else if (startHour < endHour) { status = currentHour > startHour && currentHour < endHour; } if (options.auto_dark === true) { // Skip the write (and the storage.onChanged cascade it would trigger) when the // computed state already matches dark_mode, so the 60s timer is a cheap no-op. if (status === options.dark_mode) return; options.dark_mode = status; chrome.storage.sync.set({ "dark_mode": status }, toggleDarkMode); } } // } function toggleAutoDarkMode() { clearInterval(timeCheck); if (options.auto_dark && options.auto_dark === false) return; autoDarkModeCheck(); timeCheck = setInterval(autoDarkModeCheck, 60000); } let iframeObserver; function runiframeChecker() { if (current_page === "/" || current_page === "") return; if (options.dark_mode !== true) { if (iframeObserver) iframeObserver.disconnect(); document.querySelectorAll('iframe').forEach((frame) => { if (frame.contentDocument && frame.contentDocument.documentElement && frame.contentDocument.documentElement.querySelector('#darkcss')) { frame.contentDocument.documentElement.querySelector('#darkcss').textContent = ''; frame.contentDocument.body.classList.remove("canvasrefined--darkmode--enabled"); } }); return; } const callback = (mutationList) => { for (const mutation of mutationList) { if (mutation.type !== 'childList' || !mutation.addedNodes.length) continue; for (const node of mutation.addedNodes) { if (node.nodeName !== 'IFRAME') continue; // Cross-origin iframes expose no contentDocument; access it safely so we // don't throw a TypeError into the console on every added iframe. let doc; try { doc = node.contentDocument; } catch (_) { continue; } if (!doc || !doc.documentElement || !doc.body) continue; try { const new_style_element = document.createElement("style"); new_style_element.textContent = generateDarkModeCSS(); new_style_element.id = "darkcss"; doc.body.classList.add("canvasrefined--darkmode--enabled"); doc.documentElement.prepend(new_style_element); } catch (_) { /* cross-origin or detached frame: ignore */ } } } }; iframeObserver = new MutationObserver(callback); iframeObserver.observe(document.documentElement, { childList: true, subtree: true }); } /* Hide personal details (screenshot privacy mode) */ // Pregenerated pool of fake courses (15 entries). Every real course is mapped // to a unique entry (stable per course via a hash, collisions probed away), // so the same course always shows the same fake name everywhere — dashboard // cards, the Better Todo List and recent feedback. const FAKE_COURSES = [ { name: "Algebra II", teacher: "Martinez" }, { name: "AP Biology", teacher: "Nguyen" }, { name: "Art History", teacher: "Kowalski" }, { name: "Astronomy", teacher: "Delgado" }, { name: "Chemistry", teacher: "Patel" }, { name: "Computer Science", teacher: "Brooks" }, { name: "Creative Writing", teacher: "Ellis" }, { name: "European History", teacher: "Fischer" }, { name: "Geography", teacher: "Okafor" }, { name: "Marine Biology", teacher: "Silva" }, { name: "Physics", teacher: "Jensen" }, { name: "Psychology", teacher: "Moreau" }, { name: "Spanish III", teacher: "Rivera" }, { name: "Statistics", teacher: "Chen" }, { name: "World History", teacher: "Adler" }, ]; const FAKE_SCHOOL_NAME = "Ridgeview High School"; const FAKE_ASSIGNMENT_TITLES = [ "Homework 4", "Reading Response", "Lab Report 2", "Practice Problems", "Unit Quiz", "Essay Draft", "Worksheet 7", "Group Project", "Chapter Review", "Study Guide", "Discussion Post", "Exit Ticket", "Vocabulary Quiz", "Lab Worksheet", "Project Proposal", "Test Review", "Notes Check", "Article Summary", "Problem Set 3", "Class Survey", ]; const FAKE_FEEDBACK_COMMENTS = [ "Nice work!", "Good effort.", "Well done!", "Keep it up!", "Solid job!", "Great improvement!", "Strong submission.", "Excellent detail.", ]; // FNV-1a — small, dependency-free, deterministic string hash. function anonHash(str) { let h = 2166136261; const s = String(str); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } // Same hash, but mixed with the "Reroll classes" seed so the popup button can // reshuffle every fake course/assignment/grade at once without a page refresh. function anonSeededHash(key) { return anonHash(String(key) + "/" + String(options.anon_grade_seed ?? 0)); } // key -> index into FAKE_COURSES. Hash-based so a course keeps its fake name // across reloads, with linear probing so every real course gets a UNIQUE fake // course. Once the pool is exhausted (more than 15 courses), reuse is allowed. const anonCourseMap = new Map(); function getAnonCourse(key) { const k = String(key ?? "unknown"); if (!anonCourseMap.has(k)) { const used = new Set(anonCourseMap.values()); let idx = anonSeededHash(k) % FAKE_COURSES.length; if (used.size < FAKE_COURSES.length) { while (used.has(idx)) idx = (idx + 1) % FAKE_COURSES.length; } anonCourseMap.set(k, idx); } return FAKE_COURSES[anonCourseMap.get(k)]; } function fakeCourseLabel(key) { const c = getAnonCourse(key); return `${c.name} - ${c.teacher}`; } // "a:" when the id is known, "t:" otherwise. function fakeAssignmentTitle(key) { return FAKE_ASSIGNMENT_TITLES[anonSeededHash(key) % FAKE_ASSIGNMENT_TITLES.length]; } function fakeFeedbackComment(key) { return FAKE_FEEDBACK_COMMENTS[anonSeededHash(key) % FAKE_FEEDBACK_COMMENTS.length]; } // Deterministic fake "x out of 20" score for recent feedback entries. function fakeFeedbackScore(key) { const earned = 13 + (anonSeededHash(key) % 8); return `${earned} out of 20`; } // Letter-grade mixes for the "Fake grade level" dropdown. Each preset is a // general blend of letter grades centered on that level — real gradebooks // have spread, so a screenshot shows a believable mix (mostly that level, // with a tail of grades above and below) instead of a wall of identical // letters. Weights are percentages and sum to 100. const ANON_GRADE_MIXES = { high: [["A+", 16], ["A", 30], ["A-", 22], ["B+", 14], ["B", 10], ["B-", 8]], medhigh: [["A", 5], ["A-", 13], ["B+", 22], ["B", 24], ["B-", 18], ["C+", 12], ["C", 6]], med: [["B", 5], ["B-", 12], ["C+", 21], ["C", 24], ["C-", 19], ["D+", 12], ["D", 7]], medlow: [["C", 5], ["C-", 12], ["D+", 20], ["D", 23], ["D-", 20], ["F", 20]], }; // Letters ordered highest -> lowest, used to derive each letter's percent band // from the user's own GPA cutoffs (a "B+" lives at [B+.cutoff, A-.cutoff)). const ANON_LETTER_ORDER = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F"]; // Deterministic fake percent for a key: pick a letter from the selected // preset's mix, then a plausible percent inside that letter's band on the // user's own GPA scale, so the number and letter always agree (the GPA // calculator re-derives the letter from the percent). function fakeGradePercent(key) { const mix = ANON_GRADE_MIXES[options.anon_grade_level] || ANON_GRADE_MIXES.high; const bounds = options.gpa_calc_bounds; // Letter pick and percent pick use different hash channels so two // courses with the same letter still get different percentages. let pick = anonSeededHash(key) % 100; let letter = mix[mix.length - 1][0]; for (const [l, weight] of mix) { if (pick < weight) { letter = l; break; } pick -= weight; } // Small outlier chance: ~1 in 9 courses jumps two letters up or down // from its drawn grade, like the one surprise class every real transcript // seems to have. Outliers never leave the A..F range. if (anonSeededHash(key + ":out") % 9 === 0) { const dir = anonSeededHash(key + ":dir") % 2 === 0 ? -2 : 2; const idx = ANON_LETTER_ORDER.indexOf(letter); letter = ANON_LETTER_ORDER[Math.min(ANON_LETTER_ORDER.length - 1, Math.max(0, idx + dir))]; } const idx = ANON_LETTER_ORDER.indexOf(letter); const min = bounds && bounds[letter] ? Number(bounds[letter].cutoff) : 70; const upper = bounds && idx > 0 && bounds[ANON_LETTER_ORDER[idx - 1]] ? Number(bounds[ANON_LETTER_ORDER[idx - 1]].cutoff) : 100; const max = Math.max(min + 0.5, upper - 0.1); // never reach the next letter's cutoff let percent = Math.round((min + (anonSeededHash(key + ":pct") % 997) / 1000 * (max - min)) * 10) / 10; if (percent > max) percent = max; return percent; } function fakeGradeText(key) { const percent = fakeGradePercent(key); const letter = percentToLetterGrade(percent); return (letter ? `${letter} ` : "") + `${percent}%`; } // Fake GPA (out of 4) for the calculator's cumulative row: take the letter of // the fake grade and look up what the user's own GPA scale awards for it. function fakeCumulativeGpa() { const letter = percentToLetterGrade(fakeGradePercent("cumulative")); const bounds = options.gpa_calc_bounds; return bounds && bounds[letter] ? bounds[letter].gpa : 3.5; } // Term strings look like "2026/2027 - Campolindo High School - Year": keep the // year and the term type, swap the school name for a fake one. function anonFakeTerm(orig) { const parts = String(orig).split(" - ").map(p => p.trim()).filter(p => p !== ""); if (parts.length >= 3) return `${parts[0]} - ${FAKE_SCHOOL_NAME} - ${parts[parts.length - 1]}`; return FAKE_SCHOOL_NAME; } // Render-time helpers so text baked into todo/assignment templates is fake // from the start (a DOM pass alone can't beat async re-renders clobbering it). function anonCourseNameForItem(item) { const real = item?.context_name ?? ""; if (options.hide_personal_details !== true) return real; const cid = item?.course_id ?? item?.context_id; // Numeric course ids hash under the same "id:<n>" key the dashboard cards // use, so a course shows the SAME fake name everywhere (card, todo list, // fake items impersonating it). const key = (cid != null && /^\d+$/.test(String(cid))) ? "id:" + cid : (cid != null ? String(cid) : "name:" + String(real).trim().toLowerCase()); return fakeCourseLabel(key); } function anonTitleForItem(item) { const real = item?.plannable?.title ?? ""; if (options.hide_personal_details !== true) return real; const id = item?.plannable_id ?? item?.plannable?.id; return fakeAssignmentTitle(id != null ? "a:" + id : "t:" + String(real).trim()); } // Fallback palette for fake todo items when the dashboard has no colored // cards to impersonate (fresh install / no card data). const ANON_FAKE_COLORS = ["#E24C4B", "#E28227", "#7A9E29", "#0B99AF", "#6A68D9", "#9B59D0", "#C24A9F", "#357FA3"]; // Pool of real dashboard courses fake todo items impersonate. Using the // user's actual courses means fake items inherit their real card colors and // the same fake name their dashboard card shows, so the todo list matches // the dashboard in a screenshot. Courses hidden from the dashboard or from // the todo list are excluded so a fake item never appears for a course whose // real items wouldn't. function getAnonFakeCoursePool() { const cards = options.custom_cards_3 || {}; const pool = []; Object.keys(cards).forEach(id => { const card = cards[id]; if (!card || typeof card !== "object") return; if (!/^\d+$/.test(String(id))) return; // only real course ids if (isCourseHidden(id) || isCourseTodoHidden(id)) return; pool.push({ id: String(id), color: card.color || null }); }); return pool; } // "Fill the todo list with fake courses" (Hide personal details sub-option): // builds extra planner-style items impersonating the user's real dashboard // courses (same color, same fake name the card shows), so a screenshot of // the Better Todo List doesn't reveal how many real courses or assignments // exist. Items are deterministic per reroll seed and marked with anonFake so // click/hover handlers never hit the Canvas API with fake ids. function buildFakeTodoItems() { const types = ["assignment", "quiz", "assignment", "discussion_topic", "assignment", "quiz", "assignment"]; const dayOffsets = [0, 1, 2, 3, 5, 8, 12]; const dueHours = [23, 23, 20, 23, 18, 23, 22]; const pool = getAnonFakeCoursePool(); const now = Date.now(); // "Fake tasks completed" (popup spinner): how many of the fake items show // as done. The progress rings count them, so this sets the x/y in the // rings' "x/y done" readout (and the Completed tab, if the user opens it). const rawDone = parseInt(options.anon_fake_completed); const doneCount = isNaN(rawDone) ? 2 : Math.max(0, Math.min(types.length, rawDone)); // Which items are done is a seeded shuffle so rerolling changes it too. const doneSet = new Set( types.map((_, i) => i) .sort((a, b) => anonSeededHash("done:" + b) - anonSeededHash("done:" + a)) .slice(0, doneCount) ); const items = []; for (let i = 0; i < types.length; i++) { let courseId, color; if (pool.length > 0) { // Each fake item is attributed to one of the user's real courses // (picked per seed), so it inherits that course's dashboard color // and its anonymized name. const pick = pool[anonSeededHash("pick:" + i) % pool.length]; courseId = pick.id; color = pick.color || ANON_FAKE_COLORS[anonSeededHash(String(courseId)) % ANON_FAKE_COLORS.length]; } else { // No colored dashboard cards available — fall back to anonymous // courses with a neutral palette. courseId = "anonfake-" + i; color = ANON_FAKE_COLORS[anonSeededHash(String(courseId)) % ANON_FAKE_COLORS.length]; } const id = -(anonSeededHash("todoId:" + i) % 900000) - 1; const due = new Date(now + dayOffsets[i] * 86400000); due.setHours(dueHours[i], 59, 0, 0); const isDone = doneSet.has(i); items.push({ anonFake: true, anonColor: color, plannable_id: id, plannable_type: types[i], plannable_date: due.toISOString(), plannable: { id: id, title: fakeAssignmentTitle("a:" + id) }, course_id: courseId, context_id: courseId, context_name: fakeCourseLabel(courseId), html_url: "#", // Done fake items carry a submitted+graded submission so both the // rings and the Completed tab count them; unfinished ones don't. submissions: isDone ? { submitted: true, graded: true } : { submitted: false }, }); } return items; } // Marked-text helpers. Original text/attributes are stashed in dataset entries // so toggling the option off restores the real content in place. function anonText(el, computeFake) { if (!el || el.dataset.crAnonOrig !== undefined) return; const orig = el.textContent; el.dataset.crAnonOrig = orig; el.textContent = computeFake(orig); } function anonAttr(el, attr, computeFake) { if (!el) return; let attrs = {}; if (el.dataset.crAnonAttrs !== undefined) { attrs = JSON.parse(el.dataset.crAnonAttrs); if (attrs[attr] !== undefined) return; } else { el.dataset.crAnonAttrs = "{}"; } const orig = el.getAttribute(attr); if (orig == null) return; attrs[attr] = orig; el.dataset.crAnonAttrs = JSON.stringify(attrs); el.setAttribute(attr, computeFake(orig)); } function restoreAnonymized(root = document) { root.querySelectorAll("[data-cr-anon-orig]").forEach(el => { el.textContent = el.dataset.crAnonOrig; delete el.dataset.crAnonOrig; }); root.querySelectorAll("[data-cr-anon-attrs]").forEach(el => { try { const attrs = JSON.parse(el.dataset.crAnonAttrs); for (const [attr, value] of Object.entries(attrs)) el.setAttribute(attr, value); } catch (e) { /* malformed marker: leave as-is */ } delete el.dataset.crAnonAttrs; }); } // Anonymize one dashboard card's visible + screenreader text. function anonymizeDashboardCard(card) { const link = card.querySelector('.ic-DashboardCard__link[href*="/courses/"]'); const idMatch = link ? (link.getAttribute("href") || "").match(/courses\/(\d+)/) : null; const key = idMatch ? "id:" + idMatch[1] : "name:" + (card.querySelector(".ic-DashboardCard__header-title")?.textContent || "").trim().toLowerCase(); const fakeLabel = fakeCourseLabel(key); // Snapshot the real names BEFORE anything is anonymized so substring // replacement in aria-labels/screenreader spans works off originals. // Both forms matter: the h2 text is the course nickname ("Calculus BC - // Schoen") while the title attr/subtitle use the full course name. const titleEl = card.querySelector(".ic-DashboardCard__header-title"); const origNames = []; if (titleEl) { origNames.push((titleEl.getAttribute("title") || "").trim()); origNames.push(titleEl.textContent.trim()); } const subtitleEl = card.querySelector(".ic-DashboardCard__header-subtitle"); if (subtitleEl) { origNames.push(subtitleEl.textContent.trim()); origNames.push((subtitleEl.getAttribute("title") || "").trim()); } const uniqueOrigNames = [...new Set(origNames.filter(n => n && n.length > 1))]; const replaceNames = (s) => { let out = String(s); uniqueOrigNames.forEach(n => { out = out.split(n).join(fakeLabel); }); return out; }; if (titleEl) { const inner = titleEl.querySelector("span") || titleEl; anonText(inner, () => fakeLabel); anonAttr(titleEl, "title", () => fakeLabel); } if (subtitleEl) { anonText(subtitleEl, () => fakeLabel); anonAttr(subtitleEl, "title", () => fakeLabel); } const termEl = card.querySelector(".ic-DashboardCard__header-term"); if (termEl) { anonText(termEl, anonFakeTerm); anonAttr(termEl, "title", anonFakeTerm); } // querySelectorAll only matches descendants, so the card's own // aria-label needs handling separately. anonAttr(card, "aria-label", replaceNames); card.querySelectorAll("[aria-label]").forEach(el => anonAttr(el, "aria-label", replaceNames)); card.querySelectorAll(".screenreader-only").forEach(el => anonText(el, replaceNames)); // The fake grade is normally written directly by insertGrades(); this // covers cards rendered before that ran or with grades unavailable. const gradeEl = card.querySelector(".canvasrefined-card-grade"); if (gradeEl && gradeEl.textContent.trim() !== "") anonText(gradeEl, () => fakeGradeText(key)); } // Anonymize one recent-feedback entry (the classic right-sidebar widget, which // the Better Todo List re-appends as-is). function anonymizeFeedbackLink(a) { const details = a.querySelector(".event-details"); if (!details) return; const href = a.getAttribute("href") || ""; const cMatch = href.match(/courses\/(\d+)/); const aMatch = href.match(/assignments\/(\d+)/); const contextEl = details.querySelector(".event-details__context"); const key = cMatch ? "id:" + cMatch[1] : "name:" + (contextEl ? contextEl.textContent : "").trim().toLowerCase(); const fakeLabel = fakeCourseLabel(key); if (contextEl) anonText(contextEl, () => fakeLabel); const titleEl = details.querySelector(".recent_feedback_title"); if (titleEl) anonText(titleEl, () => fakeAssignmentTitle(aMatch ? "a:" + aMatch[1] : "t:" + titleEl.textContent.trim())); const scoreEl = details.querySelector("p strong"); if (scoreEl && /out of|\/.|%/.test(scoreEl.textContent)) { anonText(scoreEl, () => fakeFeedbackScore(aMatch ? "a:" + aMatch[1] : "t:" + (titleEl ? titleEl.textContent : ""))); } // The comment is the last <p> and is quoted feedback text. const ps = details.querySelectorAll("p"); const commentP = ps.length ? ps[ps.length - 1] : null; if (commentP && commentP !== scoreEl?.parentElement && !commentP.querySelector("strong")) { anonText(commentP, (orig) => { const t = orig.trim(); return t.startsWith('"') ? `"${fakeFeedbackComment(aMatch ? "a:" + aMatch[1] : "t:" + t)}"` : fakeFeedbackComment(aMatch ? "a:" + aMatch[1] : "t:" + t); }); } } // One anonymizer sweep over the current DOM. function anonymizePersonalDetails(root = document) { if (options.hide_personal_details !== true) return; try { root.querySelectorAll(".ic-DashboardCard").forEach(anonymizeDashboardCard); root.querySelectorAll(".recent_feedback a[href]").forEach(anonymizeFeedbackLink); } catch (e) { logError(e); } } // Course image removal (small checkbox under the toggle): hides the course // photos on dashboard cards via a style element, so no upload/refresh is // needed and unchecking restores them instantly. function applyCourseImageHiding() { const on = options.hide_course_images === true; let style = document.getElementById("canvasrefined-hide-course-images"); if (on && !style) { style = document.createElement("style"); style.id = "canvasrefined-hide-course-images"; style.textContent = ` .ic-DashboardCard__header_image { background-image: none !important; } .ic-DashboardCard__header_image img { display: none !important; } .canvasrefined-link-image { display: none !important; } `; (document.head || document.documentElement).append(style); } else if (!on && style) { style.remove(); } } let personalDetailsObserver = null; let anonPassScheduled = false; // Master switch: starts/stops the DOM watcher and (de)anonymizes in place. function setupPersonalDetailsHiding() { applyCourseImageHiding(); if (options.hide_personal_details === true) { if (!personalDetailsObserver) { personalDetailsObserver = new MutationObserver(() => { if (anonPassScheduled) return; anonPassScheduled = true; requestAnimationFrame(() => { anonPassScheduled = false; anonymizePersonalDetails(); }); }); personalDetailsObserver.observe(document.documentElement, { childList: true, subtree: true }); } anonymizePersonalDetails(); } else { if (personalDetailsObserver) { personalDetailsObserver.disconnect(); personalDetailsObserver = null; } restoreAnonymized(); } } /* Dashboard grades */ // Map a percentage to a letter grade using the user's configurable GPA // calculator cutoffs (A+ down to F). Returns null when no grade is present. // Picks the letter with the HIGHEST cutoff the percent meets so the result // doesn't depend on the key order of the stored bounds object — theme imports // can reorder keys (e.g. alphabetically, where "A" precedes "A+"), which made // "+" grades unreachable and displayed e.g. 100% as "A". Cutoffs are coerced // with Number() so string values carried in by imported themes still match. function percentToLetterGrade(percent) { const bounds = options.gpa_calc_bounds; if (!bounds || typeof percent !== "number") return null; let best = null; let bestCutoff = -Infinity; for (const letter of Object.keys(bounds)) { const cutoff = Number(bounds[letter]?.cutoff); if (Number.isFinite(cutoff) && percent >= cutoff && cutoff > bestCutoff) { best = letter; bestCutoff = cutoff; } } return best; } function insertGrades() { if (options.dashboard_grades === true) { grades.then(data => { try { let cards = document.querySelectorAll('.ic-DashboardCard'); if (cards.length === 0 || cards[0].querySelectorAll(".ic-DashboardCard__link").length === 0) return; for (let i = 0; i < cards.length; i++) { let course_id = parseInt(cards[i].querySelector(".ic-DashboardCard__link").href.split("courses/")[1]); data.forEach(grade => { if (course_id === grade.id) { let gradepercent = grade.enrollments[0].has_grading_periods === true ? grade.enrollments[0].current_period_computed_current_score : grade.enrollments[0].computed_current_score; //let gradepercent = grade.enrollments[0].computed_current_score; let percent = (gradepercent || "--") + "%"; if (options.card_letter === true) { const letter = percentToLetterGrade(gradepercent); if (letter) percent = `${letter} ${percent}`; } let gradeContainer = cards[i].querySelector(".canvasrefined-card-grade") || makeElement("a", cards[i].querySelector(".ic-DashboardCard__header"), { "className": "canvasrefined-card-grade" }); // "Hide personal details": show a deterministic fake grade instead. gradeContainer.textContent = options.hide_personal_details === true ? fakeGradeText("id:" + course_id) : percent; if (options.grade_hover === true) { gradeContainer.classList.add("canvasrefined-hover-only"); } else { gradeContainer.classList.remove("canvasrefined-hover-only"); } gradeContainer.setAttribute("href", `${domain}/courses/${course_id}/grades`); gradeContainer.style.display = "block"; } }); } } catch (e) { logError(e); } }).catch(e => logError(e)); } else { document.querySelectorAll('.canvasrefined-card-grade').forEach(grade => { grade.style.display = "none"; }); } } /* Card assignments */ function createCardAssignment(assignment) { let assignmentContainer = document.createElement("div"); assignmentContainer.className = "canvasrefined-assignment-container"; let assignmentName = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": anonTitleForItem(assignment), "href": assignment.html_url }); let assignmentDueAt = makeElement("span", assignmentContainer, { "className": "canvasrefined-assignment-dueat", "textContent": formatCardDue(new Date(assignment.plannable_date)) }); if (assignment.overdue === true) assignmentDueAt.classList.add("canvasrefined-assignment-overdue"); if (assignment?.submissions?.submitted === true) { assignmentContainer.classList.add("canvasrefined-completed"); } else { if (options.assignment_states[assignment.plannable_id]?.["crs"] === true) { assignmentContainer.classList.add("canvasrefined-completed"); } } assignmentDueAt.addEventListener('mouseup', function () { assignmentContainer.classList.toggle("canvasrefined-completed"); const status = assignmentContainer.classList.contains("canvasrefined-completed"); setAssignmentState(assignment.plannable_id, { "crs": status, "expire": assignment.plannable_date }); }); return assignmentContainer; } let cardAssignments; /* Equal Height Cards: stretch each card's assignment area to match the tallest one, using min-height so cards can still grow. */ let equalHeightResizeTimer = null; function equalizeCardHeights() { const cards = document.querySelectorAll(".ic-DashboardCard"); if (cards.length === 0) return; const enabled = options.equal_height_cards === true && options.assignments_due === true; // Clear prior min-height so we can measure fresh or fully reset. cards.forEach(card => { const area = card.querySelector(".canvasrefined-card-assignment"); if (area) area.style.removeProperty("min-height"); }); if (!enabled) return; // Stretch each assignment area to the tallest one. let maxHeight = 0; cards.forEach(card => { const area = card.querySelector(".canvasrefined-card-assignment"); if (area) maxHeight = Math.max(maxHeight, area.offsetHeight); }); if (maxHeight > 0) { cards.forEach(card => { const area = card.querySelector(".canvasrefined-card-assignment"); if (area) area.style.minHeight = maxHeight + "px"; }); } } window.addEventListener("resize", () => { if (equalHeightResizeTimer) clearTimeout(equalHeightResizeTimer); equalHeightResizeTimer = setTimeout(equalizeCardHeights, 150); }); function preloadAssignmentEls() { return new Promise((resolve) => { let assignmentEls = {}; const now = new Date(); // Resolve (never reject, never hang): a throw inside the data callback // previously left this promise pending forever, so every dashboard // card kept showing its blinking "loading" skeleton indefinitely. const finish = () => resolve(assignmentEls); if (!assignments || typeof assignments.then !== "function") { finish(); return; } assignments.then(data => { try { data = combineAssignments(data); data.forEach(item => { let due = new Date(item.plannable_date); item.overdue = now >= due; let o = { "submitted": item.submissions && item.submissions.submitted === true, "override": item.planner_override && item.planner_override.marked_complete, "type": item.plannable_type, "due": due, "el": createCardAssignment(item) } if (assignmentEls[item.course_id]) { assignmentEls[item.course_id].push(o); } else { assignmentEls[item.course_id] = [o]; } }); } catch (e) { logError(e); } finally { finish(); } }).catch(e => { logError(e); finish(); }); }); } function loadCardAssignments() { if (options.assignments_due !== true) { document.querySelectorAll(".canvasrefined-card-assignment").forEach(card => { card.style.display = "none"; }); equalizeCardHeights(); return; } setupCardAssignments(); if (!cardAssignments || typeof cardAssignments.then !== "function") return; // Render-generation guard: cardAssignments is reassigned whenever the // planner data refreshes; a stale callback re-queried the live cards and // re-appended outdated rows over the fresh render. const renderGen = ++cardRenderGen; cardAssignments.then(els => { if (renderGen !== cardRenderGen) return; try { let cards = document.querySelectorAll('.ic-DashboardCard'); if (cards.length === 0) return; const now = new Date(); cards.forEach(card => { let count = 0; let link = card.querySelector(".ic-DashboardCard__link"); if (!link) return; let course_id = link.href.split("courses/")[1]; let cardContainer = card.querySelector('.canvasrefined-card-container'); if (!cardContainer) return; cardContainer.textContent = ""; if (cardContainer.parentElement) { cardContainer.parentElement.style.display = "block"; } if (els[course_id]) { els[course_id].forEach(assignment => { if (count >= options.num_assignments) return; if (options.hide_completed_cards === true && assignment.submitted === true) return; if ((options.card_overdues !== true && now >= assignment.due) || (options.card_overdues === true && assignment.submitted === true)) return; if (assignment.type !== "assignment" && assignment.type !== "quiz" && assignment.type !== "discussion_topic") return; if (assignment.override === true) return; //assignment.el.querySelector(".canvasrefined-assignment-dueat").textContent = formatCardDue(assignment.due); cardContainer.appendChild(assignment.el); count++; }); } if (count === 0) { let assignmentContainer = makeElement("div", cardContainer, { "className": "canvasrefined-assignment-container" }); let assignmentDivLink = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": "None" }); } }); // Wait one frame so the browser lays out the freshly appended // assignment rows before measuring/equalizing card heights. requestAnimationFrame(equalizeCardHeights); } catch (e) { logError(e); } }); } function setupCardAssignments() { if (options.assignments_due !== true) return; try { let containersCount = document.querySelectorAll('.canvasrefined-card-container').length; if (document.querySelectorAll('.ic-DashboardCard').length > 0 && containersCount > 0) return; let cards = document.querySelectorAll('.ic-DashboardCard'); cards.forEach(card => { let assignmentContainer = card.querySelector(".canvasrefined-card-assignment") || makeElement("div", card, { "className": "canvasrefined-card-assignment" }); let assignmentsDueHeader = card.querySelector(".canvasrefined-card-header-container") || makeElement("div", assignmentContainer, { "className": "canvasrefined-card-header-container" }); let assignmentsDueLabel = card.querySelector(".canvasrefined-card-header") || makeElement("h3", assignmentsDueHeader, { "className": "canvasrefined-card-header", "textContent": chrome.i18n.getMessage("due") }); let cardContainer = card.querySelector(".canvasrefined-card-container") || makeElement("div", assignmentContainer, { "className": "canvasrefined-card-container" }); let skeletonText = card.querySelector(".canvasrefined-skeleton-text") || makeElement("div", cardContainer, { "className": "canvasrefined-skeleton-text" }); }); } catch (e) { logError(e); } } /* Card customization */ function getCardId(card) { let link = card.querySelector(".ic-DashboardCard__link"); if (!link) return -1; let href = link.href; if (!href || !href.includes("courses/")) return -1; let id = href.split("courses/")[1]; if (!id) return -1; // no ~ if (!id.includes("~")) return id; // has ~ but dashboard card method is used if (options["custom_cards"][id]) return id; // weird case, some canvases replace consecutive 0s with a ~ in the id // but the number of 0s isn't consistent between schools id = id.split("~"); let re = new RegExp(`${id[0]}0+${id[1]}`); for (const c of Object.keys(options["custom_cards"])) { if (c.match(re)) return c; } return -1; } function customizeCards(c = null) { if (!options.custom_cards) return; try { let cards = c ? c : document.querySelectorAll('.ic-DashboardCard'); if (cards.length && cards.length > 0 && cards[0].querySelectorAll(".ic-DashboardCard__link").length === 0) return; cards.forEach(card => { const id = getCardId(card); let cardOptions = options["custom_cards"][id] || null; let cardOptions_2 = options["custom_cards_2"][id] || null; if (!cardOptions) return; // hide card card.style.display = cardOptions.hidden === true ? "none" : "inline-block"; // card image if (cardOptions.img === "none") { let currentImg = card.querySelector(".ic-DashboardCard__header_image"); if (currentImg) { card.querySelector(".ic-DashboardCard__header_hero").style.opacity = 1; } } else if (cardOptions.img !== "") { let topColor = card.querySelector(".ic-DashboardCard__header_hero"); let container = card.querySelector(".ic-DashboardCard__header_image") || makeElement("div", card, { "className": "ic-DashboardCard__header_image" }); card.querySelector(".ic-DashboardCard__header").prepend(container); container.appendChild(topColor); container.style.backgroundImage = "url(\"" + cardOptions.img + "\")"; topColor.style.opacity = .5; } // card name if (cardOptions.name !== "") { card.querySelector(".ic-DashboardCard__header-title > span").textContent = cardOptions.name; } // card code if (cardOptions.code !== "") { card.querySelector(".ic-DashboardCard__header-subtitle").textContent = cardOptions.code; } // card links let links = card.querySelectorAll(".ic-DashboardCard__action"); for (let i = links.length; i < 4; i++) { makeElement("a", card.querySelector(".ic-DashboardCard__action-container"), { "className": "ic-DashboardCard__action" }); } links = card.querySelectorAll(".ic-DashboardCard__action"); for (let i = 0; i < 4; i++) { let img = links[i].querySelector(".canvasrefined-link-image") || makeElement("img", links[i], { "className": "canvasrefined-link-image" }); links[i].style.display = "inherit"; if (cardOptions_2.links[i].path === "none") { links[i].style.display = "none"; } else if (cardOptions_2.links[i].is_default === false) { links[i].href = cardOptions_2.links[i].path; img.src = getCustomLinkImage(cardOptions_2.links[i].path); if (links[i].querySelector(".ic-DashboardCard__action-layout")) links[i].querySelector(".ic-DashboardCard__action-layout").style.display = "none"; img.style.display = "block"; } else { if (links[i].querySelector(".ic-DashboardCard__action-layout")) links[i].querySelector(".ic-DashboardCard__action-layout").style.display = "inherit"; img.style.display = "none"; } img.addEventListener("error", () => { img.src = "https://www.instructure.com/favicon.ico"; }) } }); // Hiding/unhiding cards changes how many cards sit in each grid row, // so recompute the uneven-row centering offset after visibility is set. centerUnevenGridRows(); } catch (e) { logError(e); } } function getCustomLinkImage(path) { if (path.includes("webassign.net")) { return "https://www.cengage.com/favicon.ico"; } else if (path.includes("docs.google")) { return "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico"; } else { let url = { "hostname": "instructure.com/" }; try { url = new URL(path); } catch (e) { logError(e); } return "https://" + url.hostname + "/favicon.ico";; } } /* GPA calculator */ function calculateGPA2() { let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0; document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => { const weight = course.querySelector('.canvasrefined-course-weight').value; const credits = parseFloat(course.querySelector('.canvasrefined-course-credit').value); const grade = parseFloat(course.querySelector('.canvasrefined-course-percent').value); if (weight === "dnc" || !credits || !grade) return; let letter = "--"; let gpa; if (grade >= options.gpa_calc_bounds["A+"].cutoff) { gpa = options.gpa_calc_bounds["A+"].gpa; letter = "A+"; } else if (grade >= options.gpa_calc_bounds["A"].cutoff) { gpa = options.gpa_calc_bounds["A"].gpa; letter = "A"; } else if (grade >= options.gpa_calc_bounds["A-"].cutoff) { gpa = options.gpa_calc_bounds["A-"].gpa; letter = "A-"; } else if (grade >= options.gpa_calc_bounds["B+"].cutoff) { gpa = options.gpa_calc_bounds["B+"].gpa; letter = "B+"; } else if (grade >= options.gpa_calc_bounds["B"].cutoff) { gpa = options.gpa_calc_bounds["B"].gpa; letter = "B"; } else if (grade >= options.gpa_calc_bounds["B-"].cutoff) { gpa = options.gpa_calc_bounds["B-"].gpa; letter = "B-" } else if (grade >= options.gpa_calc_bounds["C+"].cutoff) { gpa = options.gpa_calc_bounds["C+"].gpa; letter = "C+"; } else if (grade >= options.gpa_calc_bounds["C"].cutoff) { gpa = options.gpa_calc_bounds["C"].gpa; letter = "C"; } else if (grade >= options.gpa_calc_bounds["C-"].cutoff) { gpa = options.gpa_calc_bounds["C-"].gpa; letter = "C-"; } else if (grade >= options.gpa_calc_bounds["D+"].cutoff) { gpa = options.gpa_calc_bounds["D+"].gpa; letter = "D+"; } else if (grade >= options.gpa_calc_bounds["D"].cutoff) { gpa = options.gpa_calc_bounds["D"].gpa; letter = "D"; } else if (grade >= options.gpa_calc_bounds["D-"].cutoff) { gpa = options.gpa_calc_bounds["D-"].gpa; letter = "D-"; } else { letter = "F"; gpa = options.gpa_calc_bounds["F"].gpa; } course.querySelector(".canvasrefined-gpa-letter-grade").textContent = letter; let weightMultiplier = 0; if (weight === "ap") { weightMultiplier = 1; } else if (weight === "honors") { weightMultiplier = .5; } qualityPoints += gpa * credits; weightedQualityPoints += (gpa + weightMultiplier) * credits; numCredits += credits; }); document.querySelector("#canvasrefined-gpa-unweighted").textContent = (qualityPoints / numCredits).toFixed(2); document.querySelector("#canvasrefined-gpa-weighted").textContent = (weightedQualityPoints / numCredits).toFixed(2); const cGPA = document.querySelector("#canvasrefined-cumulative-gpa"); const g = parseFloat(cGPA.querySelector(".canvasrefined-course-percent").value); const c = parseInt(cGPA.querySelector(".canvasrefined-course-credit").value); document.querySelector("#canvasrefined-gpa-cumulative").textContent = (((options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c)) / (numCredits + c)).toFixed(2); } function changeGPASettings(course_id, update) { calculateGPA2(); chrome.storage.sync.get(["custom_cards", "cumulative_gpa"], storage => { if (course_id === "cumulative") { chrome.storage.sync.set({ "cumulative_gpa": { ...storage["cumulative_gpa"], ...update } }); } else { chrome.storage.sync.set({ "custom_cards": { ...storage["custom_cards"], [course_id]: { ...storage["custom_cards"][course_id], ...update } } }); } }); } function createGPACalcCourse(location, course) { let customs; if (course.access_restricted_by_date === true) { return null; } if (course.id === "cumulative") { customs = options["cumulative_gpa"]; } else if (options.custom_cards && options.custom_cards[course.id]) { customs = options.custom_cards[course.id]; } else { return; customs = { "name": course.name, "hidden": false, "weight": "regular", "credits": 1, "gr": null }; } if (customs.hidden === true) return; let courseContainer = makeElement("div", location, { "className": course.id === "cumulative" ? "canvasrefined-gpa-cumulative" : "canvasrefined-gpa-course", "innerHTML": '<div class="canvasrefined-gpa-letter-grade"></div>' }); let courseName = makeElement("p", courseContainer, { "className": "canvasrefined-gpa-name", "textContent": customs.name === "" ? course.course_code : customs.name }); let changerContainer = makeElement("div", courseContainer, { "className": "canvasrefined-gpa-percent-container" }); let credits = makeElement("div", courseContainer, { "className": "canvasrefined-course-credits", "innerHTML": '<input class="canvasrefined-course-credit" value="1"></input><span class="canvasrefined-course-percent-sign">cr</span>' }); let creditsChanger = credits.querySelector(".canvasrefined-course-credit"); creditsChanger.value = customs.credits; let changer = makeElement("input", changerContainer, { "className": "canvasrefined-course-percent" }); let percent = makeElement("span", changerContainer, { "className": "canvasrefined-course-percent-sign", "textContent": course.id === "cumulative" ? "/4" : "%" }); let courseGrade = course?.enrollments[0].has_grading_periods === true ? course.enrollments[0].current_period_computed_current_score : course.enrollments[0].computed_current_score; // "Hide personal details": the calculator gets the same fake grade the // dashboard card shows for this course (same "id:<id>" key, so rerolling // keeps them consistent). The cumulative row gets a fake GPA derived from // the fake grade's letter on the user's own scale. const anonGpa = options.hide_personal_details === true; if (anonGpa) { changer.value = course.id === "cumulative" ? fakeCumulativeGpa() : fakeGradePercent("id:" + course.id); } else if (customs["gr"] !== null) { changer.value = customs["gr"]; } else if (courseGrade) { changer.value = courseGrade; } else { changer.value = "--"; } // Same fake label the dashboard card uses for this course. if (anonGpa && course.id !== "cumulative") { courseName.textContent = fakeCourseLabel("id:" + course.id); } if (course.id !== "cumulative") { let weightSelections = makeElement("form", courseContainer, { "className": "canvasrefined-course-weights" }); weightSelections.innerHTML = '<select name="weight-selection" class="canvasrefined-course-weight"><option value="dnc">Do not count</option><option value="regular">Regular/College</option><option value="honors">Honors</option><option value="ap">AP/IB</option></select>'; let weightChanger = weightSelections.querySelector(".canvasrefined-course-weight"); // Count the course only when it has a real grade (or a saved custom // one) — a fake grade must not resurrect courses the user left blank. weightChanger.value = (courseGrade || customs["gr"] !== null) ? customs.weight : "dnc"; weightChanger.addEventListener('change', () => changeGPASettings(course.id, { "weight": weightSelections.querySelector(".canvasrefined-course-weight").value })); let useCustomGr = makeElement("input", courseContainer, { "className": "canvasrefined-course-customgr", "type": "checkbox", "checked": customs.gr !== null ? true : false }); let useCustomGrLabel = makeElement("span", courseContainer, { "className": "canvasrefined-course-customgr-label", "textContent": "Save custom grade" }); useCustomGr.addEventListener("input", () => { if (options["custom_cards"][course.id]) { if (options["custom_cards"][course.id]["gr"] !== undefined && options["custom_cards"][course.id]["gr"] !== null) { changer.value = courseGrade; changeGPASettings(course.id, { "gr": null }); } else { changeGPASettings(course.id, { "gr": changer.value }); } } // While personal details are hidden the input holds a fake grade; // put it back so a stray toggle can't reveal (or save) the real one. if (options.hide_personal_details === true) changer.value = fakeGradePercent("id:" + course.id); }); } changer.addEventListener('input', (e) => { // While personal details are hidden the input shows a fake grade — // recalculate the display but never persist fake values to storage. if (options.hide_personal_details === true) { calculateGPA2(); return; } if (course.id === "cumulative" || (options["custom_cards"][course.id]["gr"] !== undefined && options["custom_cards"][course.id]["gr"] !== null)) { changeGPASettings(course.id, { "gr": e.target.value }); } else { calculateGPA2(); } }); credits.querySelector(".canvasrefined-course-credit").addEventListener('input', () => changeGPASettings(course.id, { "credits": credits.querySelector(".canvasrefined-course-credit").value })); return courseContainer; } function setupGPACalc() { if (current_page !== "/" && current_page !== "") return; try { grades?.then(result => { const sortableContainer = document.querySelector(".ic-DashboardCard__box__container"); const dashboardContainer = sortableContainer || document.querySelector("#DashboardCard_Container"); if (!dashboardContainer) return; let container2 = document.querySelector(".canvasrefined-gpa-card"); let container = document.querySelector(".canvasrefined-gpa"); const alreadyRendered = container2?.dataset?.canvasrefinedGpaRendered === "true" && container?.dataset?.canvasrefinedGpaRendered === "true"; if (!container2) { container2 = document.createElement("div"); container2.className = "canvasrefined-gpa-card"; } if (!container) { container = document.createElement("div"); container.className = "canvasrefined-gpa"; } container2.style.display = options.gpa_calc === true ? "inline-block" : "none"; if (!alreadyRendered) { container2.innerHTML = `<h3 class="canvasrefined-gpa-header">GPA</h3><div><div><p id="canvasrefined-gpa-unweighted"></p><p>Current</p></div><div style="display:${options["gpa_calc_weighted"] ? "block" : "none"}"><p id="canvasrefined-gpa-weighted"></p><p>Weighted</p></div><div style="display:${options["gpa_calc_cumulative"] ? "block" : "none"}"><p id="canvasrefined-gpa-cumulative"></p><p>Cumulative</p></div></div>`; let editBtn = makeElement("button", container2, { "className": "canvasrefined-gpa-edit-btn", "textContent": "Edit Calculator" }); container.innerHTML = '<h3 class="canvasrefined-gpa-header">GPA Calculator</h3><div class="canvasrefined-gpa-courses-container"><div class="canvasrefined-gpa-courses"></div></div>'; if (options.gpa_calc_prepend === true) { dashboardContainer.prepend(container2); dashboardContainer.prepend(container); } else { dashboardContainer.appendChild(container2); dashboardContainer.appendChild(container); } let location = document.querySelector(".canvasrefined-gpa-courses"); if (!location) return; let cumulative = createGPACalcCourse(location, { "id": "cumulative", "enrollments": [{ "has_grading_periods": true, "current_period_computed_current_score": 0 }] }); cumulative.id = "canvasrefined-cumulative-gpa"; result.forEach(course => createGPACalcCourse(location, course)); container.style.display = "none"; editBtn.addEventListener("click", () => { if (container.style.display === "none") { container.style.display = "inline-block"; editBtn.textContent = "Close Calculator"; } else { container.style.display = "none"; editBtn.textContent = "Edit Calculator"; } // Opening/closing changes the card grid's row composition // (the expanded calculator claims a full-width row), so // uneven-row centering has to recompute either way. requestAnimationFrame(centerUnevenGridRows); }); container2.dataset.canvasrefinedGpaRendered = "true"; container.dataset.canvasrefinedGpaRendered = "true"; } else { const weighted = container2.querySelector("#canvasrefined-gpa-weighted")?.parentElement; const cumulative = container2.querySelector("#canvasrefined-gpa-cumulative")?.parentElement; if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none"; if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none"; const shouldPrepend = options.gpa_calc_prepend === true; if (shouldPrepend) { if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) { dashboardContainer.insertBefore(container, dashboardContainer.firstChild); dashboardContainer.insertBefore(container2, container.nextSibling); } } else { if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) { dashboardContainer.appendChild(container2); dashboardContainer.appendChild(container); } } } try { if (sortableContainer && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) { window.jQuery(sortableContainer).sortable('refresh'); } } catch (e) {} calculateGPA2(); }).catch(e => logError(e)); } catch (e) { logError(e); } } /* Dashboard notes */ let dashboardNotesTimer; function delayDashboardNotesStorage(text) { clearTimeout(dashboardNotesTimer); dashboardNotesTimer = setTimeout(() => { chrome.storage.sync.set({ dashboard_notes_text: text }); }, 250); } /* Fallback Markdown renderer used only if js/markdown.js failed to load. */ function crRenderMarkdownFallback(src) { if (src == null) return ""; const escapeHtml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").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) => `<img src="${sanitizeUrl(url)}" alt="${alt}"${title ? ` title="${title}"` : ""}>`); h = h.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) => `<a href="${sanitizeUrl(url)}" target="_blank" rel="noopener noreferrer"${title ? ` title="${title}"` : ""}>${txt}</a>`); h = h.replace(/`([^`\n]+)`/g, (m, c) => `<code>${c}</code>`); h = h.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>"); h = h.replace(/~~([^~]+?)~~/g, "<del>$1</del>"); h = h.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1<em>$2</em>"); 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(`<h${l}>${inline(h[2])}</h${l}>`); i++; continue; } if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push("<hr>"); 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(`<blockquote>${q.join("<br>")}</blockquote>`); 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(`<li class="cr-task" data-cr-task="${taskSeq++}"><input type="checkbox"${/x/i.test(tk[1]) ? " checked" : ""}> ${inline(tk[2])}</li>`); } else { items.push(`<li>${inline(m[1])}</li>`); } i++; } out.push(`<ul>${items.join("")}</ul>`); 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(`<li>${inline(m[1])}</li>`); i++; } out.push(`<ol>${items.join("")}</ol>`); 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(`<p>${para.map(inline).join("<br>")}</p>`); } 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 = ` <div class="canvasrefined-notes-toolbar" role="toolbar" aria-label="Format notes"> <button type="button" class="cr-fmt" data-action="bold" title="Bold (Ctrl/Cmd+B)"><strong>B</strong></button> <button type="button" class="cr-fmt" data-action="italic" title="Italic (Ctrl/Cmd+I)"><em>I</em></button> <button type="button" class="cr-fmt" data-action="strike" title="Strikethrough"><s>S</s></button> <button type="button" class="cr-fmt" data-action="code" title="Inline code"><code></></code></button> <span class="cr-fmt-sep"></span> <button type="button" class="cr-fmt" data-action="h1" title="Heading 1">H1</button> <button type="button" class="cr-fmt" data-action="h2" title="Heading 2">H2</button> <span class="cr-fmt-sep"></span> <button type="button" class="cr-fmt" data-action="list" title="Bullet list">•</button> <button type="button" class="cr-fmt" data-action="numbered" title="Numbered list">1.</button> <button type="button" class="cr-fmt" data-action="task" title="Task list">☐</button> <button type="button" class="cr-fmt" data-action="quote" title="Quote">“</button> <span class="cr-fmt-sep"></span> <button type="button" class="cr-fmt" data-action="link" title="Insert link">Link</button> <button type="button" class="cr-fmt" data-action="hr" title="Horizontal rule">—</button> <button type="button" class="cr-fmt" data-action="codeblock" title="Code block">```</button> </div> <div class="canvasrefined-notes-surface"> <div class="canvasrefined-notes-rendered" tabindex="0" aria-label="Dashboard notes — click to edit" title="Click to edit"></div> <textarea class="canvasrefined-notes-editor" placeholder="Type away!" spellcheck="false"></textarea> </div> `; 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 <body> // via author CSS, and the custom-font rule's `*` selector overrides it. To // keep <p> 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 <style> is first injected), with Canvas's default Lato // stack as fallback. let originalPageFontFamily = null; const CANVAS_DEFAULT_FONT_STACK = '"Lato Extended", Lato, "Helvetica Neue", Helvetica, Arial, sans-serif'; function captureOriginalPageFont() { if (originalPageFontFamily) return; // Too late to capture once our rule is live: body's computed style would // already be the custom font. if (document.querySelector("#custom_font")) return; try { const family = getComputedStyle(document.body || document.documentElement).fontFamily; if (family) originalPageFontFamily = family; } catch (_) { /* keep fallback */ } } function originalPageFontOrFallback() { return originalPageFontFamily || CANVAS_DEFAULT_FONT_STACK; } function customFontRules() { if (options.custom_font_skip_p !== true) { return `${customFontSelector()} {font-family: ${options.custom_font.family}!important}`; } return `${customFontSelector()} {font-family: ${options.custom_font.family}!important}\n` + `p, p * {font-family: ${originalPageFontOrFallback()}!important}`; } // Selector for the custom font rule. With "Keep original assignment font" // on, <p> (and everything inside one) is carved out with :not() so the // paragraph override rule below is the only thing styling them. function customFontSelector() { return options.custom_font_skip_p === true ? "*:not(p):not(p *), input, a, button, h1, h2, h3, h4, h5, h6, span" : "*, input, a, button, h1, h2, h3, h4, h5, h6, p, span"; } function loadCustomFont() { // Quiz safe mode: don't override fonts on quiz pages. if (quizSafeModeActive()) return; let link = document.querySelector("#custom_font_link"); let style = document.querySelector("#custom_font"); let load = () => { if (options.custom_font.link !== "") { // Capture the original font before the custom rule is injected. captureOriginalPageFont(); document.head.appendChild(style); link.href = `https://fonts.googleapis.com/css2?family=${options.custom_font.link}&display=swap`; link.rel = "stylesheet"; document.head.appendChild(link); } style.textContent = options.custom_font.link === "" ? "" : customFontRules(); } let createEls = () => { link = document.createElement("link"); link.id = "custom_font_link"; style = document.createElement("style"); style.id = "custom_font"; load(); } if (link && style) { load(); } else if (options.custom_font.link !== "") { if (document.readyState !== 'loading') { createEls(); } else { document.addEventListener("DOMContentLoaded", () => { createEls(); }); } } } /* Smaller features */ // Debounced wrapper around applyAestheticChanges for card-style options that // can fire many storage onChanged events in quick succession (number inputs). // See the "cardPadding"/"imageSize"/etc. cases in applyOptionsChanges. let aestheticDebounceTimer = null; function debouncedApplyAestheticChanges(delay = 150) { if (aestheticDebounceTimer) clearTimeout(aestheticDebounceTimer); aestheticDebounceTimer = setTimeout(() => { aestheticDebounceTimer = null; applyAestheticChanges(); }, delay); } function applyAestheticChanges() { // Quiz safe mode: don't inject custom layout/aesthetic CSS on quiz pages. if (quizSafeModeActive()) return; let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style'); style.id = "canvasrefined-aesthetics"; style.textContent = ""; if (options.condensed_cards === true) style.textContent += ".ic-DashboardCard__header_hero {height:60px!important}.ic-DashboardCard__header-subtitle, .ic-DashboardCard__header-term{display:none}"; if (options.remlogo === true) style.textContent += ".ic-app-header__logomark-container{display:none}"; if (options.disable_color_overlay === true) style.textContent += ".ic-DashboardCard__header_hero{opacity: 0!important} .ic-DashboardCard__header-button-bg{opacity: 1!important}"; if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!important}"; if (options.center_cards === true) style.textContent += ".ic-DashboardCard__box__container{display:flex!important;flex-wrap:wrap!important;justify-content:center!important;align-items:flex-start!important}"; // Card Grid: lay the dashboard card container out as a strict columns x // rows grid chosen in the popup, with separate column/row spacing. // Added after center_cards so it wins the !important tie-break if both // are on. Hidden cards are display:none, so they don't take up grid // cells. Cards beyond the chosen cells fall into zero-height implicit // rows that overflow-hidden clips away — hence the "cards may be cut // off" warning in the popup. When Center Cards is also on, columns size // to the cards' natural width (they have a fixed width anyway) and the // whole grid is centered horizontally, mirroring what Center Cards does // in flex mode. // // Columns are doubled into "sub-columns" (each card spans 2 of them) so // "Center uneven rows" can shift a partial last row by half a card — // e.g. 3 cards under a 4-card row has exactly one column of leftover // space, which needs a half-column offset to look centered. Empty // sub-columns just make the odd case possible; full rows are unaffected. // Tracks are always max-content (Canvas cards have a fixed width, so the // tracks hug them exactly): spacing stays identical whether Center Cards // is on or off, instead of 1fr stretching cells and widening the visual // gaps. Center Cards then just centers the whole grid with // justify-content:center. // // The GPA calculator elements live in this same container (appended by // setupGPACalc). Two spare auto-height template rows are reserved so the // GPA card and its expanded calculator still get real rows when the // course cards fill the whole card area (otherwise they'd land in the // zero-height implicit rows that overflow-hidden clips away). When the // GPA card instead flows into an open slot inside the card area, the // spare rows stay empty and collapse to 0. if (options.card_grid === true) { const gridCols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12); const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12); // Canvas gives dashboard cards their own natural top/left margins // for the default flow layout. The grid positions everything with // its own row/column gaps, so strip those natural margins top and // left — otherwise every card sits visibly lower and further right // than the grid math says it should. (Card Spacing's right/bottom // margins are untouched.) style.textContent += `.ic-DashboardCard__box__container{margin-top:0!important;margin-left:0!important}`; style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-top:0!important;margin-left:0!important}`; const centerGrid = options.center_cards === true; const gpaOn = options.gpa_calc === true; const gpaTop = options.gpa_calc_prepend === true; const flexGrid = options.card_grid_flex === true; if (flexGrid) { // Flexible grid: fixed-width tracks (cards keep their natural // width — no stretching). While the container is wide it lays out // exactly the user's column count; as it narrows, @container // queries — measured against the card container's REAL width, so // there's no window/chrome-width guessing — step the count down // precisely when the next column would no longer fit. Tracks are // always a full card wide, so cards can't overlap; extra cards // wrap into auto-height rows instead of being clipped. const cardW = options.customCardStyles === true && parseInt(options.cardWidth, 10) > 0 ? parseInt(options.cardWidth, 10) : 262; // Container queries need an ancestor size container; the card // section wrapper is a plain full-width block, so inline-size // containment is safe there. style.textContent += `#DashboardCard_Container{container-type:inline-size}`; // Full fit (m = gridCols columns): applies whenever no narrower // breakpoint matches, i.e. only when all columns truly fit. style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols},${cardW}px)!important;grid-auto-rows:auto!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important${centerGrid ? ";justify-content:center!important" : ""}}`; // Grid gaps alone control spacing (beats the customCardStyles // margin rules emitted later via higher specificity). style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-right:0!important;margin-bottom:0!important}`; if (gpaOn) { if (gpaTop) { // Top mode: definite first-row placement, one track wide; // the expanded calculator takes the full second row. style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`; style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`; } else { // Bottom mode: the GPA card auto-places in DOM order, so it // flows right after the last course card — filling the next // open slot instead of stranding below the grid — and the // expanded calculator follows on a full-width row. style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`; } } // Step the column count down: m columns apply once m+1 no longer // fit (full fit for m columns = m*cardW + (m-1)*colGap). Emitted // widest-first so the narrowest matching rule wins the cascade. // The last step (1 column) also lets the lone card shrink with // the window instead of overflowing it. for (let m = gridCols - 1; m >= 1; m--) { const tooWideFor = (m + 1) * cardW + m * gridColGap; if (m === 1) { style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:minmax(0,1fr)!important}.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{width:100%!important;min-width:0!important}}`; } else { style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:repeat(${m},${cardW}px)!important}}`; } } } else { const totalRows = gridRows + (gpaOn ? 2 : 0); style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols * 2},minmax(0,max-content))!important;grid-template-rows:repeat(${totalRows},minmax(0,auto))!important;grid-auto-rows:0!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important;overflow:hidden!important${centerGrid ? ";justify-content:center!important" : ""}}`; style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-column:span 2!important}`; if (gpaOn) { // "Move to top" (gpa_calc_prepend) decides where the GPA card // sits: on -> pinned to the first slot (the expanded calculator // takes the full second row, and cards auto-place around both); // off -> the GPA card is a regular auto-placed item (one card // slot wide via the span rule above), so it flows into the next // open slot after the course cards — filling the leftover // spaces of a partial last row instead of squatting alone in a // dedicated row below the grid. The expanded calculator always // takes a full-width row: auto-placed right after the GPA card // in bottom mode, and display:none while collapsed so it // consumes no cells. if (gpaTop) { style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1/span 2!important}`; style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`; } else { style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`; } } } } if (options.customCardStyles === true) { if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`; if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`; // Rounds the header image band. The photo is the background of // .ic-DashboardCard__header_image, and .ic-DashboardCard__header_hero (a // child that covers the full photo with its colored overlay + 1px // border) sits on top of it. border-radius only clips the element it's // on — rounding the parent alone leaves the hero's square overlay // covering the corners, and rounding the hero alone leaves the photo's // square corners behind it — so both elements need the same radius. // Default 0; guard skips the default so stock cards keep square corners. if (options.imageRoundness !== undefined && options.imageRoundness !== 0) style.textContent += `.ic-DashboardCard__header_image, .ic-DashboardCard__header_hero {border-radius: ${options.imageRoundness}px!important;}`; if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`; if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`; // Card height sizes the image band via .ic-DashboardCard__header_hero — // the element Canvas pins at 146px that actually drives the header image // height (both .ic-DashboardCard and .ic-DashboardCard__header are // content-sized, so a height on either just clips or adds dead space). // Everything else — title, actions, and the appended // .canvasrefined-card-assignment rows — flows below the hero, so the card // grows with the assignment list and nothing gets clipped. Skipped when // condensed cards is on, since that mode pins the hero at 60px. if (options.condensed_cards !== true && options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") { style.textContent += `.ic-DashboardCard__header_hero {height: ${options.cardHeight}px!important;}`; } // Inner card padding. Applied to the whole .ic-DashboardCard box so the // hero header, title, and action buttons all get breathing room from the // card's edges. Canvas sizes the card with border-box + a fixed width, // so padding alone squishes the content area (narrower image/rows) // instead of expanding the card — switch to content-box so the padding // grows the card outward and the content keeps its full width. // Guarded by > 0 (default). if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) { style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important; box-sizing: content-box!important;}`; } } // Hiding the nav-toggle + breadcrumbs bar used to be hardcoded always-on; // it is now opt-in via the popup toggle (off by default). Its left/right // margins are always removed so the bar lines up with the content column // edges (cosmetic, light and dark mode). style.textContent += ".ic-app-nav-toggle-and-crumbs{margin-left:0!important;margin-right:0!important}"; if (options.hide_navbar === true) style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}"; if (options.custom_styles !== "") style.textContent += options.custom_styles; document.documentElement.appendChild(style); // Uneven-row centering needs the real cards (it offsets the first card of // the last partial row), so schedule it just after the style lands. Run // unconditionally while any grid setting changes: the function clears // stale offsets first, which is what makes turning the option (or the // whole grid) OFF take effect without a page refresh. requestAnimationFrame(centerUnevenGridRows); } /* Center uneven grid rows. CSS grid always packs rows from the left, so a last row with fewer cards than the chosen column count sits flush-left while the rows above are full. Pure CSS can't center a partial row, so we offset the first card of the last partial row with grid-column-start. The grid runs on double sub-columns (each card spans 2 — see applyAestheticChanges), which is what makes this possible at all: with 3 cards under a 4-card row the leftover space is one column, and centering needs a HALF-column shift — impossible on plain column lines. On sub-columns the shift is a whole sub-column (half a card + half a gap), so every remainder can be centered exactly. Only runs while Card Grid is on. Skips when the cards overflow the explicit template (those extra rows are clipped anyway, and the last visible row is full). Any previously applied offset is cleared first so re-runs (card hidden/unhidden, dashboard re-render, setting changes) start clean. */ function centerUnevenGridRows() { const container = document.querySelector(".ic-DashboardCard__box__container"); if (!container) return; // Flow items: course cards plus the GPA card (it occupies a regular card // slot — pinned first by "Move to top", otherwise auto-placed after the // cards). DOM order matches auto-placement order, so chunking this list // into rows of `cols` matches the real grid rows — including when the // expanded calculator (a full-width row between chunks) is open. const items = Array.from(container.children).filter(el => el.classList && (el.classList.contains("ic-DashboardCard") || el.classList.contains("canvasrefined-gpa-card"))); items.forEach(el => el.style.removeProperty("grid-column")); if (options.card_grid !== true || options.card_grid_center_rows !== true) return; // Flexible grid: the live column count changes with the window width, so // fixed sub-column offsets computed here would be wrong — skip centering. if (options.card_grid_flex === true) return; const cols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); const rows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); const visible = items.filter(el => getComputedStyle(el).display !== "none"); const count = visible.length; // Capacity: the explicit template rows (cards + the two GPA spare rows), // minus a full row while the expanded calculator is open (it claims one // for itself). Overflowing the template: extra items land in clipped // implicit rows and the last explicit row is full, so there's nothing to // center. const gpaExpanded = options.gpa_calc === true && (() => { const expanded = container.querySelector(":scope > .canvasrefined-gpa"); return !!expanded && getComputedStyle(expanded).display !== "none"; })(); const capacity = cols * (rows + (options.gpa_calc === true ? 2 : 0)) - (gpaExpanded ? cols : 0); if (count === 0 || count > capacity) return; const lastRowStart = Math.floor((count - 1) / cols) * cols; const inLastRow = count - lastRowStart; if (inLastRow >= cols) return; // Offset in sub-columns: each card spans 2 sub-columns, so the leftover // space is 2 * (cols - inLastRow) sub-columns and centering shifts by // half of it. The explicit start must be paired with an explicit span, // or the card would collapse to a single sub-column. Both are set with // "important" priority because the stylesheet span rule is !important // too — plain inline styles would lose to it and the offset would never // take effect. const offset = cols - inLastRow; if (offset > 0) visible[lastRowStart].style.setProperty("grid-column", `${offset + 1} / span 2`, "important"); } /* Quiz safe mode banner (pre-quiz pages only). Shows an info box explaining the extension hasn't been approved by all teachers, with a toggle for Quiz Safe Mode and a "Don't show again" button. */ function setupQuizSafeModeBanner() { if (!isQuizPreTakePage()) return; if (document.getElementById("canvasrefined-quiz-safe-banner")) return; chrome.storage.local.get("quiz_safe_mode_reminder_dismissed", local => { if (local && local.quiz_safe_mode_reminder_dismissed === true) return; chrome.storage.sync.get("quiz_safe_mode", sync => { const safeModeOn = sync && sync.quiz_safe_mode === true; injectQuizSafeModeBanner(safeModeOn); }); }); } function injectQuizSafeModeBanner(safeModeOn) { // Only inject into a real Canvas content container — never <body>, which // would place the banner outside the content area if it renders too early. const findContainer = () => document.querySelector(".ic-Layout-contentMain") || document.querySelector("#content") || document.querySelector("#main"); const insertInto = (container) => { if (!container) return false; if (document.getElementById("canvasrefined-quiz-safe-banner")) return true; const banner = makeElement("div", container, { id: "canvasrefined-quiz-safe-banner", className: "canvasrefined-quiz-safe-banner", }, true); makeElement("div", banner, { className: "canvasrefined-quiz-safe-title", textContent: "Canvas Refined — Quiz Safe Mode", }); makeElement("p", banner, { className: "canvasrefined-quiz-safe-info", textContent: "This extension hasn't been 100% approved by all teachers. Quiz Safe Mode turns off most Canvas Refined features that could interfere with this quiz page, giving you the default Canvas quiz experience.", }); const toggleRow = makeElement("div", banner, { className: "canvasrefined-quiz-safe-row" }); const toggleWrap = makeElement("label", toggleRow, { className: "canvasrefined-quiz-safe-toggle" }); const checkbox = makeElement("input", toggleWrap, { type: "checkbox" }); checkbox.checked = !!safeModeOn; checkbox.addEventListener("change", () => { chrome.storage.sync.set({ quiz_safe_mode: checkbox.checked }); // The storage.onChanged listener (applyOptionsChanges) reloads quiz pages. }); makeElement("span", toggleWrap, { className: "canvasrefined-quiz-safe-toggle-label", textContent: "Enable Quiz Safe Mode", }); const dismissBtn = makeElement("button", toggleRow, { className: "canvasrefined-quiz-safe-dismiss", type: "button", textContent: "Don't show again", title: "Hides this reminder permanently. You can still toggle Quiz Safe Mode in the extension popup.", }); dismissBtn.addEventListener("click", () => { chrome.storage.local.set({ quiz_safe_mode_reminder_dismissed: true }); banner.remove(); }); return true; }; if (insertInto(findContainer())) return; // Content container not ready yet; wait for it (never fall back to <body>). const obs = new MutationObserver(() => { if (insertInto(findContainer())) obs.disconnect(); }); obs.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(() => obs.disconnect(), 15000); } // ============================================================================= // Global Canvas Search // Search across all of the user's courses for modules, module items, and // assignments, then jump straight to them. Triggered by a floating search // button (bottom-right) or Ctrl/Cmd+K. // ============================================================================= let globalSearchIndex = null; // [{type,title,course,courseId,url}] let globalSearchIndexPromise = null; // in-flight build so concurrent opens share one fetch let globalSearchIndexAt = 0; // ms timestamp of last successful build const GLOBAL_SEARCH_INDEX_TTL = 10 * 60 * 1000; // 10 minutes const GLOBAL_SEARCH_STORAGE_KEY = "canvasrefined_global_search_index"; let _gsShortcutBound = false; function setupGlobalSearch() { if (options.global_search !== true) return; // Rebuild the index fresh on every page load so newly-concluded/hidden // courses never linger from a previous session's cache. invalidateGlobalSearchIndex(); ensureGlobalSearchButton(); ensureGlobalSearchShortcut(); } function removeGlobalSearch() { document.getElementById("canvasrefined-global-search-header-btn")?.remove(); removeGlobalSearchBetterSidebarButton(); removeGlobalSearchNativeSidebarButton(); closeGlobalSearchModal(); if (_gsPlacementObserver) { _gsPlacementObserver.disconnect(); _gsPlacementObserver = null; } if (_gsShortcutBound) { document.removeEventListener("keydown", onGlobalSearchShortcut, true); _gsShortcutBound = false; } } // Shared search icon used by the sidebar + header triggers. const GLOBAL_SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20px" height="20px"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><circle cx="11" cy="11" r="7" stroke="var(--bcsidebar-text)" stroke-width="2" fill="none"/><path d="m20 20-3.2-3.2" stroke="var(--bcsidebar-text)" stroke-width="2" stroke-linecap="round"/></g></svg>`; // Placement: a search trigger is injected into whichever left sidebar is // active — the Better Sidebar (when enabled) or Canvas' native global nav — // and, on the dashboard, a button is also placed in the header actions row. // There is no floating button. A rAF-debounced MutationObserver re-evaluates // placement as Canvas renders/SPA-navigates/rebuilds the sidebar. let _gsPlacementObserver = null; let _gsPlacementScheduled = false; function ensureGlobalSearchButton() { placeGlobalSearchTrigger(); if (_gsPlacementObserver) return; _gsPlacementObserver = new MutationObserver(() => { if (_gsPlacementScheduled) return; _gsPlacementScheduled = true; requestAnimationFrame(() => { _gsPlacementScheduled = false; placeGlobalSearchTrigger(); }); }); _gsPlacementObserver.observe(document.documentElement, { childList: true, subtree: true }); } function placeGlobalSearchTrigger() { if (options.global_search !== true) return; // Native global nav (the slim icon bar) — always present, so always add. ensureGlobalSearchNativeSidebarButton(); // Better Sidebar (extra column when the option is enabled) — add when present. const betterSidebar = document.getElementById("better-sidebar-container"); if (betterSidebar) { ensureGlobalSearchBetterSidebarButton(betterSidebar); } else { removeGlobalSearchBetterSidebarButton(); } // Dashboard header button (in addition to the sidebar triggers). const headerActions = document.querySelector(".ic-Dashboard-header__actions"); if (isDashboardPage() && headerActions) { ensureGlobalSearchHeaderButton(headerActions); } else { document.getElementById("canvasrefined-global-search-header-btn")?.remove(); } } // --- Better Sidebar trigger -------------------------------------------------- function ensureGlobalSearchBetterSidebarButton(betterSidebar) { // The first child of #better-sidebar-container is the button list. const sidebarContent = betterSidebar.querySelector("div"); if (!sidebarContent) return; if (sidebarContent.querySelector("#canvasrefined-gs-sidebar-btn")) return; const btn = document.createElement("a"); btn.id = "canvasrefined-gs-sidebar-btn"; btn.className = "canvasrefined-custom-btn better-sidebar-btn canvasrefined-gs-sidebar-btn"; btn.href = "#"; btn.title = "Search Canvas (Ctrl+K)"; btn.setAttribute("role", "button"); btn.setAttribute("aria-label", "Search Canvas"); btn.style.cssText = "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;"; btn.innerHTML = `${GLOBAL_SEARCH_ICON_SVG}<span class="better-sidebar-label" style="font-size:var(--bc-sidebar-label-size,14px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;">Search</span>`; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); }); // Append so it sits at the bottom of the sidebar item list. sidebarContent.appendChild(btn); // Match the sidebar's current expanded/collapsed mode immediately so the // button doesn't briefly render in the wrong state (e.g. label visible while // collapsed) until the next toggle calls updateSidebar(). applyGlobalSearchSidebarButtonMode(btn, betterSidebar.dataset.expanded === "true"); } function removeGlobalSearchBetterSidebarButton() { document.getElementById("canvasrefined-gs-sidebar-btn")?.remove(); } // Apply the Better Sidebar's current expanded/collapsed styling to the search // button, mirroring updateSidebar()'s button/label/svg rules so the button is // correct the moment it's inserted (and whenever the sidebar re-renders). function applyGlobalSearchSidebarButtonMode(btn, expanded) { if (!btn) return; btn.style.width = expanded ? "80%" : "40%"; const label = btn.querySelector(".better-sidebar-label"); if (label) label.style.display = expanded ? "block" : "none"; btn.querySelectorAll("svg").forEach(svg => { svg.style.width = "var(--bc-sidebar-icon-size,20px)"; svg.style.height = "var(--bc-sidebar-icon-size,20px)"; }); } // --- Native global-nav trigger ---------------------------------------------- function ensureGlobalSearchNativeSidebarButton() { const navMenu = document.getElementById("menu"); if (!navMenu) return; if (navMenu.querySelector("#canvasrefined-gs-nav-item")) return; const li = document.createElement("li"); li.id = "canvasrefined-gs-nav-item"; li.className = "ic-app-header__menu-list-item canvasrefined-gs-nav-item"; const link = document.createElement("a"); link.className = "ic-app-header__menu-list-link"; link.href = "#"; link.setAttribute("role", "button"); link.title = "Search Canvas (Ctrl+K)"; link.setAttribute("aria-label", "Search Canvas"); link.innerHTML = `<span class="menu-item-icon-container" aria-hidden="true">${GLOBAL_SEARCH_ICON_SVG}</span><span class="menu-item__text">Search</span>`; link.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); }); li.appendChild(link); // Append so the search item appears at the bottom of the global nav. navMenu.appendChild(li); } function removeGlobalSearchNativeSidebarButton() { document.getElementById("canvasrefined-gs-nav-item")?.remove(); } // --- Dashboard header trigger ----------------------------------------------- function ensureGlobalSearchHeaderButton(headerActions) { if (headerActions.querySelector("#canvasrefined-global-search-header-btn")) return; const btn = document.createElement("button"); btn.id = "canvasrefined-global-search-header-btn"; btn.type = "button"; btn.className = "canvasrefined-gs-header-btn"; btn.title = "Search Canvas (Ctrl+K)"; btn.setAttribute("aria-label", "Search Canvas"); btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg><span class="canvasrefined-gs-header-btn-label">Search</span>`; btn.addEventListener("click", openGlobalSearchModal); // Insert as the first child of the actions row so it sits just to the left // of the "Dashboard Options" (⋯) button, right-aligned with it. headerActions.insertBefore(btn, headerActions.firstChild); } function ensureGlobalSearchShortcut() { if (_gsShortcutBound) return; document.addEventListener("keydown", onGlobalSearchShortcut, true); _gsShortcutBound = true; } function onGlobalSearchShortcut(e) { // Ctrl/Cmd+K toggles the search modal. Ignore when a modal is already open // and the user is typing in its input (handled by the modal's own listener). if (!(e.ctrlKey || e.metaKey) || !(e.key === "k" || e.key === "K")) return; // Never activate on quiz pages (intro or take) so we don't interfere with // the quiz experience or the browser's native Ctrl+K. Read the URL live // because current_page can be stale after Canvas' client-side navigation. if (/^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(window.location.pathname)) return; const modal = document.getElementById("canvasrefined-global-search-modal"); if (modal && modal.dataset.open === "true") { closeGlobalSearchModal(); } else { e.preventDefault(); openGlobalSearchModal(); } } function openGlobalSearchModal() { if (document.getElementById("canvasrefined-global-search-modal")) return; // Show the platform-appropriate modifier in keybind hints (⌘ on Mac). const modKey = /Mac|iPhone|iPad/.test(navigator.platform) ? "\u2318" : "Ctrl"; const modal = document.createElement("div"); modal.id = "canvasrefined-global-search-modal"; modal.className = "canvasrefined-gs-modal"; modal.dataset.open = "true"; modal.innerHTML = ` <div class="canvasrefined-gs-card" role="dialog" aria-modal="true" aria-label="Search Canvas"> <div class="canvasrefined-gs-input-row"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="canvasrefined-gs-input-icon"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> <input id="canvasrefined-gs-input" class="canvasrefined-gs-input" type="text" placeholder="Search modules & assignments\u2026 (@coursename filters by course)" autocomplete="off" spellcheck="false" /> <button id="canvasrefined-gs-close" class="canvasrefined-gs-close" type="button" title="Close (Esc)">Esc</button> </div> <div id="canvasrefined-gs-results" class="canvasrefined-gs-results"></div> <div class="canvasrefined-gs-footer"> <span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span> <span><kbd>Enter</kbd> open</span> <span><kbd>${modKey}</kbd>+<kbd>Enter</kbd> new tab</span> <span><kbd>${modKey}</kbd>+<kbd>K</kbd> toggle search</span> <span><kbd>Esc</kbd> close</span> </div> </div>`; document.body.appendChild(modal); const input = modal.querySelector("#canvasrefined-gs-input"); const resultsEl = modal.querySelector("#canvasrefined-gs-results"); const closeBtn = modal.querySelector("#canvasrefined-gs-close"); let selected = -1; let currentResults = []; closeBtn.addEventListener("click", closeGlobalSearchModal); modal.addEventListener("mousedown", (e) => { if (e.target === modal) closeGlobalSearchModal(); }); // Escape closes; arrows + enter navigate. Bound on capture so we win over // the global Ctrl+K toggle. modal.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeGlobalSearchModal(); return; } if (e.key === "ArrowDown") { e.preventDefault(); selected = Math.min(selected + 1, currentResults.length - 1); renderGlobalSearchSelection(resultsEl, selected); } else if (e.key === "ArrowUp") { e.preventDefault(); selected = Math.max(selected - 1, 0); renderGlobalSearchSelection(resultsEl, selected); } else if (e.key === "Enter") { e.preventDefault(); const item = currentResults[selected]; if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey); } }); // Stop the global Ctrl+K handler from closing the modal while typing. input.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) e.stopPropagation(); }); let debounce = null; input.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(() => runGlobalSearch(input.value.trim(), resultsEl).then(res => { currentResults = res; selected = res.length ? 0 : -1; renderGlobalSearchSelection(resultsEl, selected); }), 120); }); requestAnimationFrame(() => input.focus()); // Kick off indexing immediately so the first keystroke is fast. ensureGlobalSearchIndex(); // Render an initial hint. resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.</div>`; } function closeGlobalSearchModal() { const modal = document.getElementById("canvasrefined-global-search-modal"); if (!modal) return; modal.remove(); } function openGlobalSearchResult(item, newTab) { if (!item || !item.url) return; if (newTab) { // Opening in a new tab keeps the search menu open so the user can keep // searching. Refocus the input for the next keystroke. window.open(item.url, "_blank", "noopener"); const input = document.getElementById("canvasrefined-gs-input"); if (input) input.focus(); } else { closeGlobalSearchModal(); window.location.href = item.url; } } function renderGlobalSearchSelection(resultsEl, selected) { const rows = resultsEl.querySelectorAll(".canvasrefined-gs-row"); rows.forEach((row, i) => { if (i === selected) { row.classList.add("canvasrefined-gs-selected"); row.scrollIntoView({ block: "nearest" }); } else row.classList.remove("canvasrefined-gs-selected"); }); } // --- Indexing --------------------------------------------------------------- // Drop any cached index so the next access rebuilds it from the API. Called // at setup time so every page load starts fresh. function invalidateGlobalSearchIndex() { globalSearchIndex = null; globalSearchIndexAt = 0; globalSearchIndexPromise = null; try { chrome.storage.local.remove(GLOBAL_SEARCH_STORAGE_KEY); } catch (_) { /* ignore */ } } async function ensureGlobalSearchIndex() { // A per-session in-memory build is shared across opens so we don't refetch // on every keystroke, but we never serve a persisted cache across reloads. if (globalSearchIndex && (Date.now() - globalSearchIndexAt) < GLOBAL_SEARCH_INDEX_TTL) { return globalSearchIndex; } if (globalSearchIndexPromise) return globalSearchIndexPromise; globalSearchIndexPromise = (async () => { const index = await buildGlobalSearchIndex(); globalSearchIndex = index; globalSearchIndexAt = Date.now(); return index; })(); try { return await globalSearchIndexPromise; } finally { globalSearchIndexPromise = null; } } async function buildGlobalSearchIndex() { let courses = []; try { // enrollment_state=active excludes concluded/inactive enrollments at the // source so we never index (or waste requests on) past-term courses. courses = await getData(`${domain}/api/v1/courses?enrollment_state=active&per_page=100`); } catch (e) { console.warn("[CanvasRefined] global search: failed to load courses", e); return []; } if (!Array.isArray(courses) || !courses.length) return []; // Skip inactive/concluded/hidden courses. `enrollment_state=active` // already filters at the source, but some institutions return past-term // courses as "active", so we double-check here: // - access_restricted_by_date (locked courses) // - concluded === true (Canvas marks concluded courses) // - term end_date in the past (when the API exposes it) // - courses the user hid from their dashboard (custom_cards.hidden) const now = Date.now(); courses = courses.filter(c => { if (!c || !c.name) return false; if (c.access_restricted_by_date === true) return false; if (c.concluded === true) return false; // term end date check (API may return term.end_at) const endAt = c?.term?.end_at || c?.end_at; if (endAt) { const end = new Date(endAt).getTime(); if (!isNaN(end) && end < now) return false; } if (isCourseHidden(c.id)) return false; return true; }); // Cap to keep request volume sane. courses = courses.slice(0, 60); // Canvas can return the same course more than once (multi-role enrollments, // cross-listed sections). Dedupe by id so we don't double-index or double-fetch. const seenCourseIds = new Set(); courses = courses.filter(c => { if (seenCourseIds.has(c.id)) return false; seenCourseIds.add(c.id); return true; }); const index = []; // Shared dedup state. Keys identify a piece of *content* regardless of where // it surfaced, so the same assignment (which Canvas exposes both as a module // item AND a standalone assignment) collapses to a single result. // - standalone assignment: `asn:<courseId>:<assignmentId>` // - module item with content_id: `<type>:<courseId>:<contentId>` // (for type "assignment" this becomes `asn:<courseId>:<contentId>` — // the SAME key as the standalone assignment, so whichever is added // first wins; we add assignments first to keep the direct URL) // - module item without content_id (external url/tool): `url:<normalizedUrl>` // - module itself: `module:<courseId>:<moduleId>` const seenContent = new Set(); await Promise.all(courses.map(async (course) => { const courseId = course.id; const courseName = course.name; const courseCode = course.course_code || courseName; // The course itself, so class-name queries surface its homepage and // the @course filter has something to match against. const courseKey = `course:${courseId}`; if (!seenContent.has(courseKey)) { seenContent.add(courseKey); index.push({ type: "Course", title: courseName, course: courseName, courseCode, courseId, url: `${domain}/courses/${courseId}` }); } // Assignments first so their direct URLs win over the module-item // versions of the same assignment. try { const assignments = await getData(`${domain}/api/v1/courses/${courseId}/assignments?per_page=100`); if (Array.isArray(assignments)) { for (const a of assignments) { if (!a || !a.name || !a.html_url) continue; const key = `asn:${courseId}:${a.id}`; if (seenContent.has(key)) continue; seenContent.add(key); index.push({ type: "Assignment", title: a.name, course: courseName, courseCode, courseId, url: a.html_url }); } } } catch (_) { /* non-fatal */ } // Modules + their items. try { const modules = await getData(`${domain}/api/v1/courses/${courseId}/modules?per_page=100`); if (Array.isArray(modules)) { for (const m of modules) { if (!m || !m.name) continue; const modKey = `module:${courseId}:${m.id}`; if (!seenContent.has(modKey)) { seenContent.add(modKey); index.push({ type: "Module", title: m.name, course: courseName, courseCode, courseId, url: `${domain}/courses/${courseId}/modules` }); } try { const items = await getData(`${domain}/api/v1/courses/${courseId}/modules/${m.id}/items?per_page=100`); if (Array.isArray(items)) { for (const it of items) { if (!it || !it.title) continue; // Skip text headers / dividers — no destination page. const itype = (it.type || "").toLowerCase(); if (itype === "subheader") continue; // Prefer the real page link (html_url). External // URL items expose external_url instead; the bare // `url` field is the API endpoint, never use it. const url = it.html_url || it.external_url; if (!url) continue; // Build a content-identity key so the same item // appearing in multiple modules (or mirroring a // standalone assignment) only produces one result. let key; if (itype === "assignment" && it.content_id) { key = `asn:${courseId}:${it.content_id}`; } else if (it.content_id) { key = `${itype}:${courseId}:${it.content_id}`; } else { key = `url:${normalizeGlobalSearchUrl(url)}`; } if (seenContent.has(key)) continue; seenContent.add(key); index.push({ type: prettyModuleItemType(it.type), title: it.title, course: courseName, courseCode, courseId, url }); } } } catch (_) { /* per-module failure is non-fatal */ } } } } catch (_) { /* per-course failure is non-fatal */ } })); // Final safety net: collapse any remaining normalized-URL duplicates (e.g. // external links whose content_id differed but resolve to the same page). const seen = new Set(); return index.filter(item => { const key = normalizeGlobalSearchUrl(item.url); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function normalizeGlobalSearchUrl(url) { if (!url) return ""; try { const u = new URL(url, domain); let path = u.pathname.replace(/\/+$/, ""); // strip trailing slashes // Ignore case + fragment/query for matching purposes. return (u.host.toLowerCase() + path).toLowerCase(); } catch (_) { // Non-absolute (shouldn't happen, but be safe) — normalize as-is. return String(url).replace(/\/+$/, "").toLowerCase(); } } function prettyModuleItemType(type) { switch ((type || "").toLowerCase()) { case "assignment": return "Assignment"; case "quiz": return "Quiz"; case "discussion": case "discussion_topic": return "Discussion"; case "externalurl": return "Link"; case "externaltool": return "External Tool"; case "file": return "File"; case "page": return "Page"; case "subheader": return "Section"; default: return type || "Item"; } } // --- Searching -------------------------------------------------------------- // Course-name matching used by the @course filter (case-insensitive, matches // anywhere in the full name or the course code, e.g. "101" or "calc"). An // empty filter (a bare "@") matches every course. function globalSearchCourseMatches(courseEntry, filter) { const f = (filter || "").toLowerCase(); if (!f) return true; return (courseEntry.title || "").toLowerCase().includes(f) || (courseEntry.courseCode || "").toLowerCase().includes(f); } // Splits "@Math 101 homework 5" into { courseFilter, content }. The filter is // the longest prefix after the last "@" that still matches at least one // course, so course names with spaces work: whatever remains becomes the // content query. Returns null when there is no @token at all (plain search). function parseGlobalSearchCourseFilter(query, courseEntries) { const at = query.lastIndexOf("@"); if (at < 0) return null; // '@' must start the query or follow whitespace, so email addresses and // URLs the user pastes in aren't split apart. if (at > 0 && !/\s/.test(query[at - 1])) return null; const before = query.slice(0, at).trim(); const rest = query.slice(at + 1); // Longest matching prefix; must end at a word boundary (or the end of the // query) so "@mathh" doesn't silently become course "math" + query "h". for (let len = rest.length; len > 0; len--) { if (len < rest.length && rest[len] !== " ") continue; const candidate = rest.slice(0, len).trim().toLowerCase(); if (!candidate) continue; if (courseEntries.some(c => globalSearchCourseMatches(c, candidate))) { return { courseFilter: candidate, content: `${before} ${rest.slice(len)}`.trim() }; } } if (!rest.trim()) return { courseFilter: "", content: before }; // bare "@" -> all courses return null; } function globalSearchRowHtml(item, i) { return ` <div class="canvasrefined-gs-row" data-i="${i}" data-url="${escapeGlobalSearchAttr(item.url)}"> <div class="canvasrefined-gs-row-main"> <span class="canvasrefined-gs-type canvasrefined-gs-type-${escapeGlobalSearchAttr((item.type || "").toLowerCase().replace(/\s+/g, "-"))}">${escapeGlobalSearchHtml(item.type || "")}</span> <span class="canvasrefined-gs-title">${escapeGlobalSearchHtml(item.title || "")}</span> </div> <span class="canvasrefined-gs-course">${escapeGlobalSearchHtml(item.course || "")}</span> </div>`; } function bindGlobalSearchRows(resultsEl, top) { resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => { // Plain click / Ctrl+click: honor modifier for new-tab behavior. row.addEventListener("click", (e) => { const url = row.getAttribute("data-url"); const item = top.find(x => x.url === url); if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey || (e.button === 1)); }); // Middle-click opens in a new tab without closing the search. row.addEventListener("auxclick", (e) => { if (e.button !== 1) return; e.preventDefault(); const url = row.getAttribute("data-url"); const item = top.find(x => x.url === url); if (item) openGlobalSearchResult(item, true); }); }); } async function runGlobalSearch(query, resultsEl) { if (!globalSearchIndex && globalSearchIndexPromise) { resultsEl.innerHTML = `<div class="canvasrefined-gs-loading">Building search index\u2026</div>`; } const index = await ensureGlobalSearchIndex(); if (!query) { resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.</div>`; return []; } if (!index || !index.length) { resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No modules or assignments found. Open the search again later if your courses are still loading.</div>`; return []; } // "@grades [course filter]" lists course grade pages instead of content. // A bare "@grades" lists every course; "@grades calc" narrows to courses // matching "calc" (same matching as the @course filter). Checked before // the generic @course parse so "@grades" never gets eaten as a course // name (e.g. a course actually called "Grades"). const gradesMatch = query.match(/(?:^|\s)@grades(?:\s+(.+))?$/i); if (gradesMatch) { const courseEntries = index.filter(i => i.type === "Course"); const filter = (gradesMatch[1] || "").trim(); const matching = courseEntries.filter(c => globalSearchCourseMatches(c, filter)); if (!matching.length) { resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">${filter ? `No course matches \u201c${escapeGlobalSearchHtml(filter)}\u201d.` : "No courses found to open grades for."}</div>`; return []; } const gradeRows = matching.map(c => ({ type: "Grades", title: c.title, course: c.course, courseId: c.courseId, url: `${domain}/courses/${c.courseId}/grades`, })); resultsEl.innerHTML = gradeRows.map((item, i) => globalSearchRowHtml(item, i)).join(""); bindGlobalSearchRows(resultsEl, gradeRows); return gradeRows; } // "@course name" narrows the search to one course. With no other words // after the filter (or a bare "@"), list the matching courses' homepages. const courseEntries = index.filter(i => i.type === "Course"); const parsed = parseGlobalSearchCourseFilter(query, courseEntries); let effectiveQuery = query; let allowedCourseIds = null; if (parsed) { effectiveQuery = parsed.content; const matching = courseEntries.filter(c => globalSearchCourseMatches(c, parsed.courseFilter)); allowedCourseIds = new Set(matching.map(c => String(c.courseId))); if (!effectiveQuery) { if (!matching.length) { resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No course matches \u201c${escapeGlobalSearchHtml(parsed.courseFilter)}\u201d.</div>`; return []; } resultsEl.innerHTML = matching.map((item, i) => globalSearchRowHtml(item, i)).join(""); bindGlobalSearchRows(resultsEl, matching); return matching; } } const q = effectiveQuery.toLowerCase(); const matches = []; for (const item of index) { // Re-check hidden status at search time so a card hidden after the index // was cached (10-min TTL) never surfaces in results. if (isCourseHidden(item.courseId)) continue; // An @course filter restricts results to that course's entries. if (allowedCourseIds && !allowedCourseIds.has(String(item.courseId))) continue; const t = (item.title || "").toLowerCase(); const c = (item.course || "").toLowerCase(); let score = -1; if (t.startsWith(q)) score = 100 - t.indexOf(q); else if (t.includes(q)) score = 60 - t.indexOf(q); else if (c.includes(q)) score = 20; // Course homepages float above item matches for class-name queries. if (score >= 0) { if (item.type === "Course" && t.startsWith(q)) score += 40; item._score = score + (t === q ? 50 : 0); matches.push(item); } } matches.sort((a, b) => b._score - a._score); const top = matches.slice(0, 50); if (!top.length) { resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.</div>`; return []; } resultsEl.innerHTML = top.map((item, i) => globalSearchRowHtml(item, i)).join(""); bindGlobalSearchRows(resultsEl, top); return top; } function escapeGlobalSearchHtml(s) { return String(s).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch])); } function escapeGlobalSearchAttr(s) { return escapeGlobalSearchHtml(s).replace(/`/g, "`"); } function changeGradientCards() { // Apply the gradient INLINE on each hero instead of via index-based CSS // rules: the gradient is always computed from that hero's actual current // course color, so it stays in sync even when cards are reordered, // re-rendered, or recolored after this pass (the old #gradientcss // "card N" selectors silently drifted onto the wrong cards). // Only background-image is set inline — background-color (the course // color) is left untouched so it can still be read/recolord later. const heroes = document.querySelectorAll('.ic-DashboardCard__header_hero'); heroes.forEach(hero => { if (options.gradient_cards !== true) { hero.style.removeProperty("background-image"); return; } const rgb = hero.style.backgroundColor; const parts = rgb.match(/\d+(\.\d+)?/g); // No inline course color (e.g. hero not painted yet): leave untouched // rather than guessing NaN colors. if (!parts || parts.length < 3) return; const [r, g, b] = [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2])]; let [h, s, l] = rgbToHsl(r, g, b); let degree = ((h % 60) / 60) >= .66 ? 30 : ((h % 60) / 60) <= .33 ? -30 : 15; let newh = h > 300 ? (360 - (h + 65)) + (65 + degree) : h + 65 + degree; hero.style.backgroundImage = `linear-gradient(115deg, hsl(${h}deg,${s}%,${l}%) 5%, hsl(${newh}deg,${s}%,${l}%) 100%)`; }); // Clean up the legacy index-based stylesheet from older versions. const legacy = document.querySelector("#gradientcss"); if (legacy) legacy.remove(); } function showUpdateMsg() { // dont run if not on dashboard const el = document.getElementById("announcementWrapper"); if (!el) return; // option off or div already created let div = document.getElementById("canvasrefined-update-msg"); if (options.show_updates !== true || options.update_msg === "") { if (div) div.style.display = "none"; return; } else if (div) { div.style.display = "flex"; return; } // first creation div = makeElement("div", el, { "id": "canvasrefined-update-msg" }); makeElement("p", div, { "textContent": options.update_msg }); const close = makeElement("button", div, { "id": "canvasrefined-update-close", "textContent": "Close" }); close.addEventListener("click", () => { readUpdate(); div.remove(); }); } function readUpdate() { chrome.storage.sync.set({ "update_msg": "" }); } /* Other functions */ function combineAssignments(data) { let combined = data; try { options.custom_assignments_overflow.forEach(overflow => { combined = combined.concat(options[overflow]); }); } catch (e) { logError(e); } // Dedupe by planner item identity (same key the cache merge uses), keeping // the LAST occurrence so locally-stored overflow entries win over fetched // ones. Without this, an item present in both the planner data and an // overflow array rendered twice — once on the dashboard cards and once per // copy in the todo list. if (Array.isArray(combined)) { const seen = new Set(); const deduped = []; for (let i = combined.length - 1; i >= 0; i--) { const item = combined[i]; if (!item) continue; const key = `${item.plannable_type}|${item.plannable_id}`; if (seen.has(key)) continue; seen.add(key); deduped.push(item); } deduped.reverse(); combined = deduped; } return combined.sort((a, b) => new Date(a.plannable_date).getTime() - new Date(b.plannable_date).getTime()); } function cleanCustomAssignments() { chrome.storage.sync.get("custom_assignments_overflow", overflows => { chrome.storage.sync.get(overflows["custom_assignments_overflow"], storage => { const now = new Date(); overflows["custom_assignments_overflow"].forEach(overflow => { let changed = false; for (let i = 0; i < storage[overflow].length; i++) { let assignmentDate = new Date(storage[overflow][i].plannable_date); if (!assignmentDate.getTime() || assignmentDate < now) { storage[overflow].splice(i, 1); changed = true; } } if (changed) chrome.storage.sync.set({ [overflow]: storage[overflow] }); }); }); }); } function getGrades() { if (options.gpa_calc === true || options.dashboard_grades === true) { grades = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}include[]=concluded&include[]=total_scores&include[]=computed_current_score&include[]=current_grading_period_scores&per_page=100`); } } function getColors() { if (options.tab_icons || options.better_todo || options.better_sidebar) { return getData(`${domain}/api/v1/users/self/colors`).then(data => { let cards = options.custom_cards_3; Object.keys(cards).forEach(key => { cards[key] = { ...cards[key], "color": data["custom_colors"]["course_" + key] ? data["custom_colors"]["course_" + key] : null }; }); chrome.storage.sync.set({ "custom_cards_3": cards }); return cards; }).catch(e => { // A failed colors fetch (e.g. an expired session) must not throw an // unhandled rejection or wipe the stored colors — keep the old ones. console.warn("Canvas Refined - could not load course colors", e); return options.custom_cards_3; }); } } function changeFavicon() { if (options.tab_icons !== true) return; let match = current_page.match(/courses\/(?<id>\d*)/); if (match && match.groups.id && options.custom_cards_3[match.groups.id]?.color) { document.querySelector('link[rel="icon"').href = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="white" width="128px" height="128px" viewBox="-192 -192 2304.00 2304.00" stroke="white"><g stroke-width="0"><rect x="-192" y="-192" width="2304.00" height="2304.00" rx="0" fill="${options.custom_cards_3[match.groups.id].color.replace("#", "%23")}" strokewidth="0"/></g><g stroke-linecap="round" stroke-linejoin="round"/><g> <path d="M958.568 277.97C1100.42 277.97 1216.48 171.94 1233.67 34.3881 1146.27 12.8955 1054.57 0 958.568 0 864.001 0 770.867 12.8955 683.464 34.3881 700.658 171.94 816.718 277.97 958.568 277.97ZM35.8207 682.031C173.373 699.225 279.403 815.285 279.403 957.136 279.403 1098.99 173.373 1215.05 35.8207 1232.24 12.8953 1144.84 1.43262 1051.7 1.43262 957.136 1.43262 862.569 12.8953 769.434 35.8207 682.031ZM528.713 957.142C528.713 1005.41 489.581 1044.55 441.31 1044.55 393.038 1044.55 353.907 1005.41 353.907 957.142 353.907 908.871 393.038 869.74 441.31 869.74 489.581 869.74 528.713 908.871 528.713 957.142ZM1642.03 957.136C1642.03 1098.99 1748.06 1215.05 1885.61 1232.24 1908.54 1144.84 1920 1051.7 1920 957.136 1920 862.569 1908.54 769.434 1885.61 682.031 1748.06 699.225 1642.03 815.285 1642.03 957.136ZM1567.51 957.142C1567.51 1005.41 1528.38 1044.55 1480.11 1044.55 1431.84 1044.55 1392.71 1005.41 1392.71 957.142 1392.71 908.871 1431.84 869.74 1480.11 869.74 1528.38 869.74 1567.51 908.871 1567.51 957.142ZM958.568 1640.6C816.718 1640.6 700.658 1746.63 683.464 1884.18 770.867 1907.11 864.001 1918.57 958.568 1918.57 1053.14 1918.57 1146.27 1907.11 1233.67 1884.18 1216.48 1746.63 1100.42 1640.6 958.568 1640.6ZM1045.98 1480.11C1045.98 1528.38 1006.85 1567.51 958.575 1567.51 910.304 1567.51 871.172 1528.38 871.172 1480.11 871.172 1431.84 910.304 1392.71 958.575 1392.71 1006.85 1392.71 1045.98 1431.84 1045.98 1480.11ZM1045.98 439.877C1045.98 488.148 1006.85 527.28 958.575 527.28 910.304 527.28 871.172 488.148 871.172 439.877 871.172 391.606 910.304 352.474 958.575 352.474 1006.85 352.474 1045.98 391.606 1045.98 439.877ZM1441.44 1439.99C1341.15 1540.29 1333.98 1697.91 1418.52 1806.8 1579 1712.23 1713.68 1577.55 1806.82 1418.5 1699.35 1332.53 1541.74 1339.7 1441.44 1439.99ZM1414.21 1325.37C1414.21 1373.64 1375.08 1412.77 1326.8 1412.77 1278.53 1412.77 1239.4 1373.64 1239.4 1325.37 1239.4 1277.1 1278.53 1237.97 1326.8 1237.97 1375.08 1237.97 1414.21 1277.1 1414.21 1325.37ZM478.577 477.145C578.875 376.846 586.039 219.234 501.502 110.339 341.024 204.906 206.338 339.592 113.203 498.637 220.666 584.607 378.278 576.01 478.577 477.145ZM679.155 590.32C679.155 638.591 640.024 677.723 591.752 677.723 543.481 677.723 504.349 638.591 504.349 590.32 504.349 542.048 543.481 502.917 591.752 502.917 640.024 502.917 679.155 542.048 679.155 590.32ZM1440 475.712C1540.3 576.01 1697.91 583.174 1806.8 498.637 1712.24 338.159 1577.55 203.473 1418.51 110.339 1332.54 217.801 1341.13 375.413 1440 475.712ZM1414.21 590.32C1414.21 638.591 1375.08 677.723 1326.8 677.723 1278.53 677.723 1239.4 638.591 1239.4 590.32 1239.4 542.048 1278.53 502.917 1326.8 502.917 1375.08 502.917 1414.21 542.048 1414.21 590.32ZM477.145 1438.58C376.846 1338.28 219.234 1331.12 110.339 1415.65 204.906 1576.13 339.593 1710.82 498.637 1805.39 584.607 1696.49 577.443 1538.88 477.145 1438.58ZM679.155 1325.37C679.155 1373.64 640.024 1412.77 591.752 1412.77 543.481 1412.77 504.349 1373.64 504.349 1325.37 504.349 1277.1 543.481 1237.97 591.752 1237.97 640.024 1237.97 679.155 1277.1 679.155 1325.37Z"/></g></svg>`; } } function getAssignments() { if (options.assignments_due === true || options.better_todo === true) { assignments = loadPlannerItems(); cardAssignments = preloadAssignmentEls(); // setupBetterTodo bails (instead of mounting a permanently-empty // shell) when the data promise isn't ready yet — the common case on // first run or after an expired session, when the initial fetch takes // seconds. Once data resolves, try mounting the sidebar in case the // dashboard has gone quiet since the last MutationObserver burst; // setupBetterTodo's own guards make this a no-op everywhere it // shouldn't run (wrong page, quiz, already mounted). assignments.then(() => setupBetterTodo()); } } // ===================== Planner items (cached) ===================== // The planner list powers the to-do list, dashboard cards, reminders, and // progress rings. It used to be re-fetched from 2000-01-01 on every page // load — dozens of sequential requests for students with long histories, // which was the main cause of the 1.0.0 slow-load reports. Now: // - items are cached in chrome.storage.local and served instantly, // - each page load only fetches a small recent window (14 days), // - a full lookback walk (max 1 year) runs at most once a week, and only // while the tab is idle, and // - items are limited to courses the student is currently enrolled in. // Canvas returns planner items oldest-first, so any fetch that starts far // in the past must page through everything newer; capping the lookback at // one year bounds both the request count and the cache size. Overdue items // older than a year are intentionally dropped. (Concluded courses are // excluded by the planner API by default, so this is history from the // user's active courses only.) const PLANNER_CACHE_KEY = "planner_cache_v1"; const PLANNER_LOOKBACK_DAYS = 365; // max history fetched from Canvas const PLANNER_WINDOW_DAYS = 14; // per-load "what changed" refresh window const PLANNER_FULL_REFRESH_DAYS = 7; // min time between full lookback walks // Hard cap on pages fetched (50 pages * 100 items = 5000 items) as a safety // net against a malformed/misbehaving next link. const PLANNER_MAX_PAGES = 50; function plannerDateDaysAgo(days) { return new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); } // Stable identity for a planner item (same pair the complete-toggle uses). function plannerItemKey(item) { return `${item.plannable_type}|${item.plannable_id}`; } // ===================== Active-enrollment filter ===================== // Reported bug: the to-do list showed assignments/announcements from old, // concluded classes. The planner API excludes concluded *courses*, but not // courses where only the student's *enrollment* has concluded (common at // term boundaries), so those items still leak through. This fetches the ids // of the user's currently-active course enrollments so planner items can be // filtered to real, current classes. Returns null on failure so callers // skip filtering instead of hiding everything. async function fetchActiveCourseIds() { const ids = new Set(); let url = `${domain}/api/v1/courses?enrollment_state=active&per_page=100`; // 10 pages * 100 courses is far beyond any real enrollment count; just a // safety net against a malformed next link. for (let page = 0; page < 10 && url; page++) { let response; let data; try { response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); data = await response.json(); } catch (e) { return null; } if (!response.ok || !Array.isArray(data)) return null; for (const course of data) { if (course && course.id != null) ids.add(String(course.id)); } url = getNextPageUrl(response.headers.get("Link")); } return ids; } // Keeps only items belonging to courses in `courseIds` (Set or array). // null/undefined means "unknown" (the enrollment fetch failed), so no // filtering is applied. Personal items with no course context (planner // notes, non-course contexts like groups) are always kept. function filterPlannerItemsByActiveCourses(items, courseIds) { if (!courseIds) return items; const active = (courseIds instanceof Set) ? courseIds : new Set(courseIds.map(String)); return items.filter(item => { const cid = item.course_id ?? (item.context_type === "course" ? item.context_id : null); return cid == null || active.has(String(cid)); }); } async function readPlannerCache() { try { const result = await chrome.storage.local.get(PLANNER_CACHE_KEY); const cache = result && result[PLANNER_CACHE_KEY]; if (cache && Array.isArray(cache.items)) return cache; } catch (e) { /* storage unavailable; fall back to live fetch */ } return null; } function writePlannerCache(items, lastFullRefresh, activeCourseIds, minRefreshedAt = 0) { try { // Cross-tab last-writer-wins guard: several Canvas tabs can refresh the // shared cache concurrently, and a slower tab merging from an older // snapshot must not overwrite a newer write. Skip when storage already // holds a cache refreshed after our snapshot was taken. Losing the // write is non-fatal — the other tab's data is fresher. chrome.storage.local.get(PLANNER_CACHE_KEY, result => { try { const current = result && result[PLANNER_CACHE_KEY]; if (current && ((current.refreshedAt || 0) > minRefreshedAt)) return; const p = chrome.storage.local.set({ [PLANNER_CACHE_KEY]: { items, lastFullRefresh, activeCourseIds, refreshedAt: Date.now() } }); if (p && typeof p.catch === "function") p.catch(() => {}); } catch (e) { /* cache write failure is non-fatal */ } }); } catch (e) { /* cache write failure is non-fatal */ } } // Fetches every page of /api/v1/planner/items with a due date on/after // `startDate`, following the Link "next" headers until exhausted. Uses the // same session/headers as getData. // Returns null when the fetch failed (network error, non-OK response such as // a 401 from an expired session, or a redirect to the login page). Callers // MUST treat null as "unknown", never as "no items" — writing a failed fetch // into the cache used to wipe real data whenever the Canvas session had // expired (the reported "reload after SSO login breaks everything" bug). async function fetchPlannerItemsSince(startDate) { const allItems = []; let url = `${domain}/api/v1/planner/items?start_date=${startDate}&per_page=100`; for (let page = 0; page < PLANNER_MAX_PAGES && url; page++) { let response; let data; try { response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); data = await response.json(); } catch (e) { return null; } if (!response.ok || !Array.isArray(data)) return null; // Deep-clone via JSON to unwrap Firefox Xray objects so nested props // are mutable (same as getData). try { allItems.push(...JSON.parse(JSON.stringify(data))); } catch (_) { allItems.push(...data); } url = getNextPageUrl(response.headers.get("Link")); } return allItems; } // Merges a fresh window fetch into the cached list. Cached items whose due // date falls inside the fetched window are replaced wholesale (catches new, // changed, and removed items); older cached items are kept as-is. function mergePlannerItems(cached, fetched, windowStartMs) { const byKey = new Map(); for (const item of cached) { const due = new Date(item.plannable_date).getTime(); if (due >= windowStartMs) continue; // superseded by the fresh fetch byKey.set(plannerItemKey(item), item); } for (const item of fetched) byKey.set(plannerItemKey(item), item); return sortAndTrimPlannerItems([...byKey.values()]); } // Drops items older than the lookback window, dedupes them by planner item // identity, and returns the list sorted by due date ascending (the order the // rest of the extension expects). Dedupe happens here so EVERY cache-write // path is idempotent — previously only mergePlannerItems deduped, so a // duplicated page from the API (or any other double-insert) could be // persisted verbatim and render the same assignment twice on cards and in // the todo list until the next successful window merge happened to clean it. function sortAndTrimPlannerItems(items) { const cutoff = Date.now() - PLANNER_LOOKBACK_DAYS * 86400000; // Map insertion keeps the LAST occurrence per key: Canvas returns items // oldest-first, so within a single (possibly self-overlapping) fetch the // later copy is the newer data. (mergePlannerItems additionally layers the // fresh fetch over the cache, so fetched data still wins there.) const byKey = new Map(); for (const item of items) { if (new Date(item.plannable_date).getTime() < cutoff) continue; byKey.set(plannerItemKey(item), item); } const trimmed = [...byKey.values()]; trimmed.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date)); return trimmed; } // Cheap change signature so consumers only re-render when something they // display actually moved: due date, submitted/graded/complete state, or an // announcement's read state. function plannerFingerprint(items) { return items.map(item => `${plannerItemKey(item)}:${item.plannable_date}:${item.submissions?.submitted ? 1 : 0}:${item.submissions?.graded ? 1 : 0}:${item.planner_override?.marked_complete ? 1 : 0}:${item.plannable?.read_state ?? ""}` ).join("|"); } // Entry point for the planner data. Resolves instantly from cache when // present; otherwise does one bounded (1-year) fetch on the critical path so // the to-do list isn't empty on first run, then caches the result. In both // cases items are limited to the student's currently-active courses. async function loadPlannerItems() { const cache = await readPlannerCache(); if (cache) { schedulePlannerRefresh(cache); // Serve instantly, filtered with the last known enrollment list; // the background refresh below brings that list up to date. return filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds); } // First run (no cache yet): fetch the bounded lookback and the active // enrollment list in parallel, then cache the filtered result. A failed // fetch (e.g. expired session) must NOT be cached: cache nothing so the // next load retries the full fetch instead of serving an empty list. const [items, courseIds] = await Promise.all([ fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)), fetchActiveCourseIds(), ]); if (items === null) return []; const filtered = filterPlannerItemsByActiveCourses( sortAndTrimPlannerItems(items), courseIds ); writePlannerCache(filtered, Date.now(), courseIds ? [...courseIds] : null); return filtered; } // Background cache refresh: a small recent window on every load (catches new // items and recent changes), plus a full lookback walk at most once every // PLANNER_FULL_REFRESH_DAYS (catches state changes on older items, e.g. a // months-old assignment finally being submitted). The active-enrollment list // is refreshed on every pass so concluded classes drop out promptly rather // than waiting for the weekly walk. Runs while the tab is idle so it never // competes with page load. function schedulePlannerRefresh(cache) { const fullRefreshDue = !cache.lastFullRefresh || (Date.now() - cache.lastFullRefresh > PLANNER_FULL_REFRESH_DAYS * 86400000); // Only one refresh may run at a time. loadPlannerItems schedules a refresh // on every cache hit (including Add-Task's re-fetch), and every open Canvas // tab runs its own; concurrent runs could interleave so a slow stale run // overwrites a newer incremental write in storage. if (plannerRefreshRunning) return; const run = async () => { if (plannerRefreshRunning) return; plannerRefreshRunning = true; try { const before = plannerFingerprint( filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds) ); const courseIds = await fetchActiveCourseIds(); // On enrollment-fetch failure, fall back to the cached list. const active = courseIds ?? (cache.activeCourseIds ?? null); const idsToStore = courseIds ? [...courseIds] : (cache.activeCourseIds ?? null); let merged; if (fullRefreshDue) { const fetched = await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)); // Fetch failed (e.g. expired session): keep the existing cache // untouched and retry on the next load. Writing the empty // result used to wipe the cache AND stamp lastFullRefresh, // locking in the data loss for a week. if (fetched === null) return; merged = filterPlannerItemsByActiveCourses( sortAndTrimPlannerItems(fetched), active ); writePlannerCache(merged, Date.now(), idsToStore, cache.refreshedAt || 0); } else { const windowStart = plannerDateDaysAgo(PLANNER_WINDOW_DAYS); const fetched = await fetchPlannerItemsSince(windowStart); // Fetch failed: skip the merge entirely. mergePlannerItems // drops cached items inside the window before adding the fresh // ones, so merging an empty/failed fetch would silently delete // the most recent two weeks from the persisted cache. if (fetched === null) return; merged = filterPlannerItemsByActiveCourses( // Drop threshold = the window start's LOCAL midnight, not // UTC midnight. The window start is a date-only string // (Date.parse → UTC midnight) while Canvas evaluates // start_date in the user's timezone: for a user west of // UTC, an item due between UTC midnight and local midnight // counts as "inside the window" by UTC but is NOT returned // by a fetch filtered from local midnight — dropping it at // the UTC threshold silently deleted it until the weekly // full walk. Thresholding at local midnight keeps exactly // the items the fetch may not return; any harmless overlap // is removed by mergePlannerItems' key dedupe (fresh wins). mergePlannerItems(cache.items, fetched, Date.parse(windowStart + "T00:00:00")), active ); writePlannerCache(merged, cache.lastFullRefresh, idsToStore, cache.refreshedAt || 0); } if (plannerFingerprint(merged) !== before) refreshPlannerConsumers(merged); } catch (e) { console.warn("planner refresh failed", e); } finally { plannerRefreshRunning = false; } }; if (typeof requestIdleCallback === "function") { requestIdleCallback(() => run(), { timeout: 20000 }); } else { setTimeout(run, 5000); } } // Re-renders everything that displays planner items after the background // refresh produced new data. Mirrors the option-change handlers so all the // existing consumers re-attach to the updated promise. function refreshPlannerConsumers(items) { assignments = Promise.resolve(items); updateReminders(); if (options.assignments_due === true || options.better_todo === true) { cardAssignments = preloadAssignmentEls(); loadCardAssignments(); } if (options.better_todo && document.getElementById("better-todo-main")) { moreAnnouncementCount = 0; moreAssignmentCount = 0; moreCompletedCount = 0; clearTodoList(); createTodoSections(document.querySelector("#canvasrefined-todo-list")); } } // Extracts the rel="next" URL from a Canvas pagination Link header, or // returns null when on the last page. function getNextPageUrl(linkHeader) { if (!linkHeader) return null; const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); return match ? match[1] : null; } // ===================== Grade Analytics ===================== // On course grades pages, adds an "Analytics" toggle on the left side (in the // Better Sidebar when enabled, otherwise in the native course nav) that shows // a panel with a score-distribution doughnut, an overall-grade-over-time // line chart, a GitHub-style grade heatmap, and a final-grade calculator. // Data comes from the Canvas API with the user's session, so it // matches the numbers on the page. Charts are hand-drawn on <canvas> so the // extension needs no CDN/library and no chart library dependency. const GA_BUCKETS = [ { label: "90+", min: 90, max: Infinity, color: "#16a34a" }, { label: "80-89", min: 80, max: 90, color: "#4ade80" }, { label: "70-79", min: 70, max: 80, color: "#facc15" }, { label: "60-69", min: 60, max: 70, color: "#fb923c" }, { label: "50-59", min: 50, max: 60, color: "#f87171" }, { label: "40-49", min: 40, max: 50, color: "#ef4444" }, { label: "30-39", min: 30, max: 40, color: "#dc2626" }, { label: "20-29", min: 20, max: 30, color: "#b91c1c" }, { label: "10-19", min: 10, max: 20, color: "#991b1b" }, { label: "0-9", min: 0, max: 10, color: "#7f1d1d" }, ]; const GA_UNGRADED_COLOR = "#6b7280"; // 5%-wide zone colors for the line chart background: the doughnut's bucket // colors interpolated at 5% steps (dark red at 0 → green at 100), so every // 5% band gets its own shade. Each band is sampled at its LOWER edge so // every decade starts on its pure bucket color — a 70 is exactly yellow, // not a yellow-green blend. const GA_ZONE_COLORS = (() => { const stops = GA_BUCKETS.slice().reverse(); // 0-9 (dark red) → 90+ (green) const rgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const lerp = (a, b, t) => Math.round(a + (b - a) * t); return Array.from({ length: 20 }, (_, i) => { const m = i * 5; // band's lower edge (70, 75, …) — see comment above const k = Math.min(stops.length - 1, Math.floor(m / 10)); if (k >= stops.length - 1) return stops[stops.length - 1].color; const t = (m - k * 10) / 10; const c1 = rgb(stops[k].color), c2 = rgb(stops[k + 1].color); return `rgb(${lerp(c1[0], c2[0], t)},${lerp(c1[1], c2[1], t)},${lerp(c1[2], c2[2], t)})`; }); })(); const GA_OPEN_KEY = "grade_analytics_open"; async function getGradeAnalyticsOpenState() { const result = await chrome.storage.local.get(GA_OPEN_KEY); return result[GA_OPEN_KEY] ?? true; } function setGradeAnalyticsOpenState(open) { chrome.storage.local.set({ [GA_OPEN_KEY]: open }); } const GA_FIT_Y_KEY = "grade_analytics_fit_y"; async function getGradeAnalyticsFitY() { const result = await chrome.storage.local.get(GA_FIT_Y_KEY); return result[GA_FIT_Y_KEY] ?? false; } function setGradeAnalyticsFitY(fit) { chrome.storage.local.set({ [GA_FIT_Y_KEY]: fit }); } // Final-grade calculator settings, stored per course so each course's final // weight and goal survive reloads: { weight, target, show }. The needed // score itself is never stored — it's always recomputed against the live // current grade. const GA_CALC_PREFIX = "grade_analytics_final_"; function gaCalcStorageKey(courseId) { return GA_CALC_PREFIX + courseId; } async function getGaCalcSettings(courseId) { const empty = { weight: null, target: null, show: false }; if (courseId == null) return empty; const key = gaCalcStorageKey(courseId); const result = await chrome.storage.local.get(key); const v = result[key]; return v && typeof v === "object" ? v : empty; } function saveGaCalcSettings() { const courseId = getCurrentCourseId(); if (courseId == null || !gaCalc) return; chrome.storage.local.set({ [gaCalcStorageKey(courseId)]: gaCalc }); } let gaObserver = null; let gaOpen = false; // panel open on this page view let gaFitY = false; // scale the line chart Y axis to fit the data let gaImagineIf = false; // "Imagine-If mode" enabled on this page view (never persisted — always off on load) let gaScenario = null; // imagine-if working copy of groups + assignments let gaIfCounter = 0; // unique ids for user-added groups/assignments let gaOriginalFinalHtml = null; // Total row's original grade span innerHTML, for restore let gaTab = "overview"; // active panel tab: "overview" | "calc" | "heatmap" let gaCalc = null; // final-grade calculator settings for this course let gaCourseId = null; // course whose data is cached let gaData = null; // computed data for the current course let gaLoading = false; let gaChartObserver = null; // ResizeObserver: redraws charts once their boxes gain a real size function gradeAnalyticsActive() { return options.grade_analytics === true && isGradesPage() && !quizSafeModeActive(); } // Grades data is read straight from the #grades_summary table the page // already rendered — no API round trips, so even courses with hundreds of // assignments populate instantly, and the numbers always match what the user // sees (grading periods, unposted grades, etc.). Waits briefly for the table // to appear on SPA navigations. function gaWaitForGradesTable(timeoutMs = 15000) { return new Promise((resolve, reject) => { const ready = () => { const table = document.querySelector("#grades_summary"); return table && table.querySelector("tr.student_assignment") ? table : null; }; const found = ready(); if (found) { resolve(found); return; } const started = Date.now(); const timer = setInterval(() => { const table = ready(); if (table) { clearInterval(timer); resolve(table); } else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error("grades table not found on this page")); } }, 250); }); } // Entry point: called at init, on SPA navigation, and when the option changes. function watchGradeAnalytics() { if (!gradeAnalyticsActive()) { removeGradeAnalyticsPanel(); if (gaObserver) { gaObserver.disconnect(); gaObserver = null; } return; } // SPA navigation between courses: drop cached data so the panel never // shows the previous course's charts. const courseId = getCurrentCourseId(); if (gaCourseId !== null && gaCourseId !== courseId) { gaData = null; gaCourseId = null; gaCalc = null; // per-course final-calculator settings removeGradeAnalyticsPanel(); } if (!gaObserver) { // Canvas re-renders the left nav and content area during SPA // navigation; the observer keeps the panel placed. gaObserver = new MutationObserver(() => scheduleGradeAnalyticsSync()); gaObserver.observe(document.documentElement, { childList: true, subtree: true }); } scheduleGradeAnalyticsSync(); // Restore the open/closed state and Y-axis preference the user last // chose, then inject the panel below the Print Grades header. Promise.all([getGradeAnalyticsOpenState(), getGradeAnalyticsFitY(), getGaCalcSettings(courseId)]).then(([open, fit, calc]) => { gaOpen = open; gaFitY = fit; gaCalc = calc; const panel = ensureGradeAnalyticsPanel(); if (panel) applyGaCalcState(panel); if (gaOpen && gaData) renderGradeAnalytics(); }); if (!gaData && !gaLoading) loadGradeAnalytics(); } let gaSyncRaf = null; function scheduleGradeAnalyticsSync() { if (gaSyncRaf) return; gaSyncRaf = requestAnimationFrame(() => { gaSyncRaf = null; syncGradeAnalyticsUI(); }); } function syncGradeAnalyticsUI() { if (!gradeAnalyticsActive()) return; const panel = ensureGradeAnalyticsPanel(); if (!panel) return; // Imagine-If: Canvas re-renders can wipe the overwritten Total block or // swap in a new grades table (grading-period switch) — reapply, and // rebuild the scenario when the table's rows changed. if (gaImagineIf) { gaRenderImagineIf(); gaApplyImagineTotal(); } // Self-heal: the one-shot render after data loads can be a no-op when the // panel was created before Canvas finished laying out the page (zero-size // canvases) or while the body was still hidden. gaSetupCanvas leaves the // canvas backing store at width 0 in that case, so a 0-width canvas means // "never drawn" — redraw now that layout is real. if (gaOpen && gaData) { const pie = panel.querySelector("#canvasrefined-ga-pie"); const line = panel.querySelector("#canvasrefined-ga-line"); if ((pie && pie.width === 0) || (line && line.width === 0)) { renderGradeAnalytics(); } } } function removeGradeAnalyticsPanel() { gaOpen = false; if (gaChartObserver) { gaChartObserver.disconnect(); gaChartObserver = null; } // Never leave a hypothetical Total or inline editors behind when the // panel goes away. gaClearImagineUI(); gaRestoreImagineTotal(); document.getElementById("canvasrefined-grade-analytics")?.remove(); } // Applies the in-memory open/closed state (restored from storage) to the panel // DOM. Called both at panel creation and whenever an already-attached panel // is reused, so a stored preference is never lost to a creation race (the DOM // observer can build the panel before the storage read resolves). function applyGradeAnalyticsOpenState(panel) { const body = panel.querySelector("#canvasrefined-ga-body"); const btn = panel.querySelector("#canvasrefined-ga-toggle"); if (!body || !btn) return; body.style.display = gaOpen ? "" : "none"; const svg = btn.querySelector("svg"); if (svg) svg.style.transform = gaOpen ? "rotate(180deg)" : "rotate(0deg)"; btn.setAttribute("aria-expanded", String(gaOpen)); } // Syncs the "Imagine-If mode" button's DOM to the in-memory state. // Called at panel creation and whenever an already-attached panel is // reused, mirroring applyGradeAnalyticsOpenState. function applyGradeAnalyticsImagineState(panel) { const btn = panel.querySelector("#canvasrefined-ga-imagine"); if (!btn) return; btn.setAttribute("aria-pressed", String(gaImagineIf)); btn.style.borderColor = gaImagineIf ? "#2563eb" : "var(--bcborders)"; btn.style.color = gaImagineIf ? "#2563eb" : "var(--bctext-0)"; btn.style.fontWeight = gaImagineIf ? "600" : ""; } // Panel is injected directly below the "Print Grades" action header on the // grades page. Returns null (and retries via the DOM observer) if the anchor // hasn't rendered yet. function ensureGradeAnalyticsPanel() { let panel = document.getElementById("canvasrefined-grade-analytics"); const anchor = document.getElementById("print-grades-container"); if (panel && panel.isConnected) { // Canvas re-renders can shift the anchor or our position; keep the // panel directly after #print-grades-container at all times. if (anchor && panel.previousElementSibling !== anchor) { anchor.insertAdjacentElement("afterend", panel); } // Re-apply the open/closed state in case it was restored from storage // after this panel was first created. applyGradeAnalyticsOpenState(panel); applyGradeAnalyticsImagineState(panel); return panel; } const container = anchor || findContentContainer(); if (!container) return null; panel = document.createElement("div"); panel.id = "canvasrefined-grade-analytics"; if (anchor) { anchor.insertAdjacentElement("afterend", panel); } else { // Fallback: top of the content container until the anchor renders. container.insertBefore(panel, container.firstChild); } panel.style.cssText = `margin:18px 0;padding:16px;border:1px solid color-mix(in srgb, var(--bcborders) 75%, transparent);border-radius:10px;background-color:var(--bcbackground-0);color:var(--bctext-0);font-family:"Lato","Helvetica Neue",Helvetica,Arial,sans-serif;box-sizing:border-box;`; panel.innerHTML = ` <div style="display:flex;align-items:center;gap:10px;"> <h2 style="margin:0;font-size:18px;color:var(--bctext-0);">Grade Analytics</h2> <button id="canvasrefined-ga-imagine" type="button" aria-pressed="false" title="Toggle Imagine-If mode" style="margin-left:auto;background:var(--bcbackground-1);color:var(--bctext-0);border:1px solid var(--bcborders);border-radius:8px;padding:4px 12px;font-size:14px;line-height:1.4;cursor:pointer;">Imagine-If mode</button> <button id="canvasrefined-ga-toggle" type="button" aria-expanded="true" title="Toggle Grade Analytics" style="background:var(--bcbackground-1);color:var(--bctext-0);border:1px solid var(--bcborders);border-radius:8px;padding:4px 12px;font-size:14px;line-height:1.4;cursor:pointer;"><svg style="transform:rotate(180deg);display:block;" fill="currentColor" width="16px" height="16px" viewBox="-6.5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6"><g stroke-width="0"/><g stroke-linecap="round" stroke-linejoin="round"/><path d="M18.813 11.406l-7.906 9.906c-0.75 0.906-1.906 0.906-2.625 0l-7.906-9.906c-0.75-0.938-0.375-1.656 0.781-1.656h16.875c1.188 0 1.531 0.719 0.781 1.656z"/></svg></button> </div> <div id="canvasrefined-ga-body"> <p id="canvasrefined-ga-status" style="margin:0 0 10px;color:var(--bctext-1);font-size:13px;">Loading grade data…</p> <div id="canvasrefined-ga-tabs" style="display:flex;gap:4px;border-bottom:1px solid color-mix(in srgb, var(--bcborders) 75%, transparent);margin-bottom:14px;"> <button type="button" data-ga-tab="overview" style="${gaTabStyle(true)}">Overview</button> <button type="button" data-ga-tab="calc" style="${gaTabStyle(false)}">Final Calculator</button> <button type="button" data-ga-tab="heatmap" style="${gaTabStyle(false)}">Heatmap</button> </div> <div id="canvasrefined-ga-tab-overview"> <div id="canvasrefined-ga-stats" style="display:none;flex-wrap:wrap;gap:10px;margin-bottom:14px;"></div> <div id="canvasrefined-ga-charts" style="display:none;gap:24px;flex-wrap:wrap;"> <div id="canvasrefined-ga-box-pie" style="flex:1 1 calc(33.333% - 8px);min-width:0;"> <h3 style="margin:0 0 8px;font-size:14px;color:var(--bctext-0);">Score distribution (graded assignments)</h3> <div style="position:relative;height:280px;"><canvas id="canvasrefined-ga-pie"></canvas><div id="canvasrefined-ga-pie-tip" style="position:absolute;display:none;pointer-events:none;background:var(--bcbackground-1);color:var(--bctext-0);border:1px solid var(--bcborders);border-radius:6px;padding:6px 10px;font-size:12px;z-index:10;white-space:nowrap;"></div></div> </div> <div id="canvasrefined-ga-box-line" style="flex:1 1 calc(66.666% - 16px);min-width:0;"> <div style="display:flex;align-items:center;gap:10px;margin:0 0 8px;"> <h3 style="margin:0;font-size:14px;color:var(--bctext-0);">Overall grade over time</h3> <label for="canvasrefined-ga-fity" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--bctext-1);cursor:pointer;user-select:none;"><input type="checkbox" id="canvasrefined-ga-fity"> Fit Y axis</label> </div> <div style="position:relative;height:280px;"><canvas id="canvasrefined-ga-line"></canvas><div id="canvasrefined-ga-line-tip" style="position:absolute;display:none;pointer-events:none;background:var(--bcbackground-1);color:var(--bctext-0);border:1px solid var(--bcborders);border-radius:6px;padding:8px 10px;font-size:12px;z-index:20;max-width:260px;box-shadow:0 4px 14px rgba(0,0,0,0.25);"></div></div> </div> </div> </div> <div id="canvasrefined-ga-tab-calc" style="display:none;"> <div style="max-width:620px;"> <p style="margin:0 0 14px;color:var(--bctext-1);font-size:13px;">Enter how much your final is worth and the overall grade you want to show see what grade you need on the final.</p> <div style="display:flex;gap:14px;flex-wrap:wrap;margin-bottom:14px;"> <label style="flex:1 1 200px;font-size:12px;color:var(--bctext-1);">Final exam weight (% of grade) <input id="canvasrefined-ga-calc-weight" type="number" min="0" max="100" step="0.1" inputmode="decimal" placeholder="20" style="display:block;width:100%;margin-top:4px;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--bcborders);background:var(--bcbackground-1);color:var(--bctext-0);font-size:15px;"> </label> <label style="flex:1 1 200px;font-size:12px;color:var(--bctext-1);">Target overall grade (%) <input id="canvasrefined-ga-calc-target" type="number" min="0" step="0.1" inputmode="decimal" placeholder="90" style="display:block;width:100%;margin-top:4px;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--bcborders);background:var(--bcbackground-1);color:var(--bctext-0);font-size:15px;"> </label> </div> <label for="canvasrefined-ga-calc-show" style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--bctext-0);cursor:pointer;user-select:none;margin-bottom:14px;"><input type="checkbox" id="canvasrefined-ga-calc-show"> Show grade goal on the overview</label> <div id="canvasrefined-ga-calc-result" style="padding:12px 16px;border-radius:8px;background:var(--bcbackground-1);border:1px solid color-mix(in srgb, var(--bcborders) 75%, transparent);font-size:14px;"></div> </div> </div> <div id="canvasrefined-ga-tab-heatmap" style="display:none;position:relative;"> <div style="overflow-x:auto;max-width:100%;padding:2px 2px 6px;"> <div id="canvasrefined-ga-heatmap-grid" style="display:inline-flex;gap:4px;"></div> </div> <div id="canvasrefined-ga-heatmap-note" style="margin:0;color:var(--bctext-1);font-size:12px;"></div> <div style="display:flex;align-items:center;gap:4px;margin-top:8px;font-size:11px;color:var(--bctext-1);"> <span>0%</span> <span style="width:11px;height:11px;border-radius:3px;display:inline-block;background:${GA_ZONE_COLORS[0]};"></span> <span style="width:11px;height:11px;border-radius:3px;display:inline-block;background:${GA_ZONE_COLORS[5]};"></span> <span style="width:11px;height:11px;border-radius:3px;display:inline-block;background:${GA_ZONE_COLORS[10]};"></span> <span style="width:11px;height:11px;border-radius:3px;display:inline-block;background:${GA_ZONE_COLORS[15]};"></span> <span style="width:11px;height:11px;border-radius:3px;display:inline-block;background:${GA_ZONE_COLORS[19]};"></span> <span>100%</span> <span style="margin-left:12px;display:inline-flex;align-items:center;gap:4px;"><span style="width:12px;height:12px;border-radius:3px;display:inline-block;background:color-mix(in srgb, var(--bctext-1) 20%, transparent);"></span>No grade/assignment</span> </div> <style>#canvasrefined-grade-analytics .canvasrefined-ga-hcell:hover{outline:1px solid var(--bctext-0);outline-offset:1px;}</style> <div id="canvasrefined-ga-heatmap-tip" style="position:absolute;display:none;pointer-events:none;z-index:100;background:var(--bcbackground-1);color:var(--bctext-0);border:1px solid var(--bcborders);border-radius:6px;padding:6px 10px;font-size:12px;white-space:nowrap;box-shadow:0 4px 14px rgba(0,0,0,0.25);"></div> </div> </div> `; 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. // Month abbreviations for parsing the grades table's due-date strings. const GA_MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; // Parse a due-date string from the grades table into sortable parts. // Handles "Sep 12", "Sep 12 at 11:59pm", "Sep 12 by 23:59" and // "Sep 12, 2024 at 11:59pm". Returns { mon, day, minutes } or null. function gaParseDueDate(due) { const m = String(due || "").toLowerCase().match(/([a-z]{3})[a-z]*\.?\s+(\d{1,2})(?:[^\d]+\d{4})?(?:[^\d]{0,8}?(\d{1,2}):(\d{2})\s*(am|pm)?)?/); if (!m) return null; const mon = GA_MONTH_NAMES.indexOf(m[1]); if (mon < 0) return null; const day = parseInt(m[2], 10); if (!isFinite(day) || day < 1 || day > 31) return null; let minutes = 0; if (m[3] !== undefined) { let h = parseInt(m[3], 10); minutes = h * 60 + parseInt(m[4], 10); if (m[5] === "pm" && h < 12) minutes += 720; if (m[5] === "am" && h === 12) minutes = parseInt(m[4], 10); } return { mon, day, minutes }; } function gaParseNum(t) { 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(), // Full hover tooltip date when present — a better parse source than // the abbreviated cell text. dueTitle: q("td.due [title]")?.getAttribute("title") || q("td.due")?.getAttribute("title") || "", }; } 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]++; } // Sort graded work chronologically by due date before building the // running-grade timeline. The chart and trend assume row order is // chronological, but the grades table isn't guaranteed to be (it follows // the course's assignment ordering, and users can re-sort it), which left // the grade-history line jumping back and forth in time. // // Due strings have no year, so months are cyclic: a school-year course // (Sep → May) and a calendar-year course (Jan → Dec) both count upward, // just from different "year starts". Each candidate rotation r (month // the year window starts at) maps a due month to (mon - r) mod 12; the // rotation whose keys produce the least total backward drift in the // table's row order wins, with ties going to the first dated row's // month (which reproduces the chart's walking-month behavior on // already-ordered data). This stays correct whether rows come in // due-date order, a different sort order, or shuffled. Day-of-month and // time break ties within a month; undated rows inherit their nearest // dated neighbour's key (forward, then backward fill); equal keys keep // their original relative order (stable sort). const parsed = graded.map(a => gaParseDueDate(a.dueTitle || a.due)); // Cost of a rotation = total backward drift it forces on the row order // (sum of positive key drops between consecutive dated rows). Magnitude // beats counting steps: a shuffle costs real months, a year-wrap costs // ~11/12 of a cycle only if it's the wrong rotation. const rotationCost = (r) => { let cost = 0, prev = null; for (const d of parsed) { if (!d) continue; const k = (d.mon - r + 12) % 12; if (prev != null && k < prev) cost += prev - k; prev = k; } return cost; }; let bestRotation = 0, bestCost = Infinity; for (let r = 0; r < 12; r++) { const cost = rotationCost(r); if (cost < bestCost) { bestCost = cost; bestRotation = r; } } const firstDated = parsed.find(d => d); if (firstDated) { const pref = firstDated.mon; // Re-scan for the preferred rotation on a cost tie. for (let r = 0; r < 12; r++) { if (rotationCost(r) === bestCost && r === pref) { bestRotation = r; break; } } } const keys = parsed.map(d => d ? [(d.mon - bestRotation + 12) % 12, d.day, d.minutes] : null); let lastKey = null; for (let i = 0; i < keys.length; i++) { if (keys[i] == null) keys[i] = lastKey; else lastKey = keys[i]; } let nextKey = null; for (let i = keys.length - 1; i >= 0; i--) { if (keys[i] == null) keys[i] = nextKey; else nextKey = keys[i]; } const order = graded.map((_, i) => i).sort((x, y) => { const kx = keys[x], ky = keys[y]; if (!kx || !ky) return x - y; for (let j = 0; j < 3; j++) if (kx[j] !== ky[j]) return kx[j] - ky[j]; return x - y; }); const sortedGraded = order.map(i => graded[i]); graded.length = 0; graded.push(...sortedGraded); // Running overall grade, in the page's row order, using Canvas's own // 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) => `<div style="padding:8px 14px;border-radius:8px;background:var(--bcbackground-1);"><div style="font-size:18px;font-weight:700;color:${color || "var(--bctext-0)"};">${val}</div><div style="font-size:11px;text-transform:uppercase;color:var(--bctext-1);">${cap}</div></div>`; // 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(2) + "%"; trendColor = "#16a34a"; } else if (gaData.trend < -0.05) { trendVal = "\u25BC " + gaData.trend.toFixed(2) + "%"; trendColor = "#dc2626"; } else { trendVal = "\u25BA " + gaData.trend.toFixed(2) + "%"; 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(2) + "%"; color = gaNeededColor(needed); } goalStat = stat("Final Exam", val, color); } stats.innerHTML = stat("Overall grade", gaData.current == null ? "-" : gaData.current.toFixed(2) + "%") + 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 = `<span style="color:var(--bctext-1);">Enter your final's weight and your target grade to see what you need on the final.</span>`; return; } const w = gaCalc.weight / 100; const target = gaCalc.target; const current = gaData ? gaData.current : null; if (current == null) { box.innerHTML = `<span style="color:var(--bctext-1);">Waiting for grade data…</span>`; 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 = `<span style="font-size:20px;font-weight:700;color:#16a34a;">You're already there!</span>`; sub = `Even a 0 on the final leaves you at <b>${withZero.toFixed(2)}%</b>, which is above your <b>${target}%</b> goal.`; } else if (needed > 100) { head = `<span style="font-size:20px;font-weight:700;color:#dc2626;">Out of reach</span>`; sub = `Even a perfect final only gets you to <b>${withPerfect.toFixed(2)}%</b>, which is below your <b>${target}%</b> goal.`; } else { head = `<span style="font-size:20px;font-weight:700;color:${gaNeededColor(needed)};">You need ≥ ${needed.toFixed(2)}% on the final</span>`; sub = `You got this!`; } box.innerHTML = head + `<div style="margin-top:6px;color:var(--bctext-1);font-size:13px;">${sub}</div>`; } // --- 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 => `<div style="margin-top:2px;color:var(--bctext-1);">${gaEscHtml(p.title)} — <b style="color:${gaHeatmapColor(p.pct)};">${p.pct.toFixed(2)}%</b></div>`).join(""); tip.innerHTML = `<b>${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}</b> — avg <b style="color:${gaHeatmapColor(avg)};">${avg.toFixed(2)}%</b><div style="margin-top:4px;font-size:11px;color:var(--bctext-1);">${cell.items.length} assignment${cell.items.length === 1 ? "" : "s"}:</div>${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, `<b>${hit.label}</b>: ${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) <= 1 ? 2 : (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), `<b>${p.title}</b><br>Overall: ${p.grade == null ? "-" : p.grade.toFixed(2) + "%"}<br>This: ${p.score}/${p.points} (${p.pct.toFixed(2)}%)${p.due ? `<br>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(2) + "%"; // 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 `<span style="display:inline-flex;align-items:center;gap:6px;">` + `<span style="background:#7c3aed;color:#fff;font-size:10px;font-weight:700;letter-spacing:0.5px;padding:1px 6px;border-radius:999px;text-transform:uppercase;white-space:nowrap;">Imagine-If</span>` + `<span style="font-weight:700;color:#7c3aed;">${pctText}</span>` + (letter ? `<span style="color:#7c3aed;">(${letter})</span>` : "") + `</span>`; } // 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;"; // <select> of the scenario's live groups for one assignment row. A group // that was removed leaves its assignments on "— not counted —" until the // user reassigns them. function gaIfSelectHtml(gid) { const groups = gaScenario ? gaScenario.groups.filter(g => !g.deleted) : []; return `<select data-ga-if-groupsel title="Imagine-If assignment group" style="${GA_IF_TD_SEL}">` + `<option value="">— not counted —</option>` + groups.map(g => `<option value="${gaEscHtml(g.gid)}"${String(gid) === String(g.gid) ? " selected" : ""}>${gaEscHtml(g.name)}</option>`).join("") + `</select>`; } // Re-renders every group <select>'s options (e.g. after a group was added or // removed), preserving the current selection when it still exists. Skips the // select the user is interacting with. function gaIfRefreshSelects(table) { for (const sel of table.querySelectorAll("select[data-ga-if-groupsel]")) { if (document.activeElement === sel) continue; const cur = sel.value; const groups = gaScenario ? gaScenario.groups.filter(g => !g.deleted) : []; sel.innerHTML = `<option value="">— not counted —</option>` + groups.map(g => `<option value="${gaEscHtml(g.gid)}"${String(cur) === String(g.gid) ? " selected" : ""}>${gaEscHtml(g.name)}</option>`).join(""); sel.value = groups.some(g => String(g.gid) === cur) ? cur : ""; } } // Icon-only remove button (✕) for a row's last cell. function gaIfDelButton(attr, title) { const btn = document.createElement("button"); btn.type = "button"; btn.setAttribute(attr, ""); btn.title = title; btn.textContent = "✕"; btn.style.cssText = GA_IF_TD_BTN + "padding:3px 8px;"; return btn; } // Injects the scenario controls into one assignment row: score (numerator) // and points possible (denominator) inputs replace the read-only "5 / 5" // display in the score cell, a group <select> replaces the group-name // context line under the title, and an icon-only remove button goes in the // row's last cell. function gaIfBuildAssignmentControls(tr, a) { if (tr.querySelector(".canvasrefined-ga-if-edit")) return; const scoreTd = tr.querySelector("td.assignment_score"); if (!scoreTd) return; const tooltip = scoreTd.querySelector("span.tooltip"); if (tooltip) { tooltip.style.display = "none"; tooltip.dataset.gaIfHidden = "1"; } const edit = document.createElement("span"); edit.className = "canvasrefined-ga-if-edit"; edit.style.cssText = "display:inline-flex;align-items:center;gap:4px;"; edit.innerHTML = `<input data-ga-if-score type="number" step="any" placeholder="—" value="${a.score == null ? "" : a.score}" title="Imagine-If score (numerator); blank = not counted" style="${GA_IF_TD_NUM}">` + ` <span style="color:var(--bctext-1);">/</span> ` + `<input data-ga-if-pts type="number" step="any" min="0" placeholder="—" value="${a.points == null ? "" : a.points}" title="Imagine-If points possible (denominator)" style="${GA_IF_TD_NUM}">`; (scoreTd.querySelector(".score_holder") || scoreTd).appendChild(edit); const th = tr.querySelector("th.title"); const ctx = th?.querySelector("div.context"); if (ctx) { ctx.style.display = "none"; ctx.dataset.gaIfHidden = "1"; } const ctl = document.createElement("div"); ctl.className = "canvasrefined-ga-if-ctl"; ctl.style.cssText = "display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:wrap;"; ctl.innerHTML = gaIfSelectHtml(a.gid); (th || tr).appendChild(ctl); // Icon-only remove button in the row's last cell. const lastTd = tr.cells[tr.cells.length - 1]; if (lastTd && !lastTd.querySelector("[data-ga-if-adel]")) { lastTd.appendChild(gaIfDelButton("data-ga-if-adel", "Remove this assignment from the scenario")); } } // Injects the scenario controls into one group-total row: a weight input in // the score cell (next to the group percentage, which shows the hypothetical // value — see gaIfUpdateGroupPcts) and an icon-only remove button in the // row's last cell. function gaIfBuildGroupControls(tr, g) { if (tr.querySelector(".canvasrefined-ga-if-edit")) return; const scoreTd = tr.querySelector("td.assignment_score"); if (!scoreTd) return; const edit = document.createElement("span"); edit.className = "canvasrefined-ga-if-edit"; edit.style.cssText = "display:inline-flex;align-items:center;gap:4px;margin-left:8px;"; edit.innerHTML = `<input data-ga-if-gweight type="number" min="0" max="100" step="0.1" value="${g.weight}" title="Imagine-If group weight (% of grade)" style="${GA_IF_TD_NUM}">` + ` <span style="color:var(--bctext-1);">%</span>`; (scoreTd.querySelector(".score_holder") || scoreTd).appendChild(edit); // Icon-only remove button in the row's last cell. const lastTd = tr.cells[tr.cells.length - 1]; if (lastTd && !lastTd.querySelector("[data-ga-if-gdel]")) { lastTd.appendChild(gaIfDelButton("data-ga-if-gdel", "Remove this group from the scenario (its assignments stop counting until reassigned)")); } } // A brand-new assignment row (user-added): same 8-cell shape as the page's // own rows, with an editable title. Custom class only — never // "student_assignment", so the table signature and the page parsers ignore // our rows. function gaIfMakeAssignmentRow(a) { const tr = document.createElement("tr"); tr.className = "canvasrefined-ga-if-newrow"; tr.innerHTML = ` <th class="title" scope="row"><input data-ga-if-title type="text" value="${gaEscHtml(a.title)}" title="Assignment name" style="${GA_IF_TD_INPUT}width:100%;max-width:260px;"></th> <td class="due"></td><td class="submitted"></td><td class="status"></td> <td class="assignment_score"><div class="score_holder" style="position:relative;"></div></td> <td class="asset_processors_cell"></td><td class="details"></td><td></td>`; // Row separators matching the page's own rows. Two things make this // subtle: the dark-mode CSS paints every .ic-Table td border dark with // !important (which beats a normal inline style), and in the // collapsed-borders model CELL borders beat ROW borders — so the // separator must be set on the cells, with !important (an inline // !important beats the stylesheet's), to win the edge conflicts and // paint the white line the custom background shows on real rows. for (const cell of tr.cells) { cell.style.setProperty("border-top", "1px solid var(--bctext-1,#e2e2e2)", "important"); cell.style.setProperty("border-bottom", "1px solid var(--bctext-1,#e2e2e2)", "important"); } return tr; } // A brand-new group-total row (user-added), with an editable name. function gaIfMakeGroupRow(g) { const tr = document.createElement("tr"); tr.className = "canvasrefined-ga-if-newrow"; tr.innerHTML = ` <th class="title" scope="row"><input data-ga-if-gname type="text" value="${gaEscHtml(g.name)}" title="Group name" style="${GA_IF_TD_INPUT}width:100%;max-width:260px;"></th> <td class="due"></td><td class="submitted"></td><td class="status"></td> <td class="assignment_score"><div class="score_holder" style="position:relative;"><span class="tooltip"><span class="grade">—</span></span></div></td> <td class="asset_processors_cell"></td><td class="details"></td><td></td>`; // Same cell-level separators as gaIfMakeAssignmentRow. for (const cell of tr.cells) { cell.style.setProperty("border-top", "1px solid var(--bctext-1,#e2e2e2)", "important"); cell.style.setProperty("border-bottom", "1px solid var(--bctext-1,#e2e2e2)", "important"); } return tr; } // New assignments go right below the table header, above the first real // assignment (stacking in add order). function gaIfInsertAssignmentRow(table, tr) { const lastNew = [...table.querySelectorAll("tr.canvasrefined-ga-if-newrow[data-ga-if-key]")].pop(); if (lastNew) { lastNew.after(tr); return; } const firstReal = [...table.querySelectorAll("tr.student_assignment")] .find(r => !r.classList.contains("group_total") && !r.classList.contains("final_grade")); if (firstReal) { firstReal.before(tr); return; } (table.querySelector("tr.group_total") || table.querySelector("tr.final_grade") || table.lastElementChild).before(tr); } function gaIfInsertGroupRow(table, tr) { const lastNew = [...table.querySelectorAll("tr.canvasrefined-ga-if-newrow[data-ga-if-gid]")].pop(); const lastReal = [...table.querySelectorAll("tr.group_total")].pop(); (lastNew || lastReal || table.querySelector("tr.final_grade") || table.lastElementChild).after(tr); } // The "+ Add assignment" button sits on its own row at the top of the // table — right below the header, above the first (or first user-added) // assignment — so new assignments are created right where they appear. function gaIfEnsureAddAssignmentRow(table) { if (table.querySelector("#canvasrefined-ga-if-add-asg")) return; const tr = document.createElement("tr"); tr.className = "canvasrefined-ga-if-addrow"; tr.innerHTML = `<td colspan="8" style="border:none!important;padding:6px 8px;">` + `<button type="button" id="canvasrefined-ga-if-add-asg" style="${GA_IF_TD_BTN}padding:4px 10px;">+ Add assignment</button>` + `</td>`; const firstNew = table.querySelector("tr.canvasrefined-ga-if-newrow[data-ga-if-key]"); const firstReal = [...table.querySelectorAll("tr.student_assignment")] .find(r => !r.classList.contains("group_total") && !r.classList.contains("final_grade")); const anchor = firstNew || firstReal; if (anchor) anchor.before(tr); else (table.querySelector("tr.group_total") || table.querySelector("tr.final_grade") || table.lastElementChild).before(tr); } // The "+ Add group" button sits on its own row right above the table's // Total row, where the group totals live. function gaIfEnsureAddGroupRow(table) { if (table.querySelector("#canvasrefined-ga-if-add-group")) return; const tr = document.createElement("tr"); tr.className = "canvasrefined-ga-if-addrow"; tr.innerHTML = `<td colspan="8" style="border:none!important;padding:10px 8px;">` + `<button type="button" id="canvasrefined-ga-if-add-group" style="${GA_IF_TD_BTN}padding:6px 12px;">+ Add group</button>` + `</td>`; (table.querySelector("tr.final_grade") || table.lastElementChild).before(tr); } // Rewrites each group row's percentage to the hypothetical value for the // current scenario (original text is stashed for restore). A dash means the // group has no counted work. function gaIfUpdateGroupPcts() { const table = document.querySelector("#grades_summary"); if (!table || !gaScenario) return; const stats = new Map(); for (const a of gaScenario.assignments) { if (a.deleted || a.score == null || !(a.points > 0)) continue; if (!gaScenario.groups.some(g => !g.deleted && String(g.gid) === String(a.gid))) continue; const s = stats.get(String(a.gid)) || { score: 0, pts: 0 }; s.score += a.score; s.pts += a.points; stats.set(String(a.gid), s); } for (const tr of table.querySelectorAll("tr[data-ga-if-gid]")) { const gradeEl = tr.querySelector("td.assignment_score .tooltip .grade"); if (!gradeEl) continue; if (gradeEl.dataset.gaIfOrig == null) gradeEl.dataset.gaIfOrig = gradeEl.textContent; const s = stats.get(String(tr.dataset.gaIfGid)); const txt = s && s.pts > 0 ? ((s.score / s.pts) * 100).toFixed(2).replace(/\.?0+$/, "") + "%" : "—"; if (gradeEl.textContent !== txt) gradeEl.textContent = txt; } } // Removes every trace of the inline UI: injected controls and rows, hidden // originals, hidden deleted rows, and the rewritten group percentages. function gaClearImagineUI(table) { table = table || document.querySelector("#grades_summary"); if (!table) return; delete table.dataset.gaIfUi; delete table.dataset.gaIfBuilt; table.querySelectorAll(".canvasrefined-ga-if-edit, .canvasrefined-ga-if-ctl, tr.canvasrefined-ga-if-newrow, tr.canvasrefined-ga-if-addrow").forEach(el => el.remove()); table.querySelectorAll("[data-ga-if-hidden]").forEach(el => { el.style.display = ""; delete el.dataset.gaIfHidden; }); table.querySelectorAll(".grade[data-ga-if-orig]").forEach(el => { el.textContent = el.dataset.gaIfOrig; delete el.dataset.gaIfOrig; }); table.querySelectorAll("tr[data-ga-if-deleted]").forEach(tr => { tr.style.display = ""; delete tr.dataset.gaIfDeleted; }); table.querySelectorAll("tr[data-ga-if-key], tr[data-ga-if-gid]").forEach(tr => { delete tr.dataset.gaIfKey; delete tr.dataset.gaIfGid; }); } // One delegated listener set on the grades table handles every scenario // edit, so rows can be injected/rebuilt freely without detaching handlers. function gaIfBindTable(table) { if (table.dataset.gaIfBound) return; table.dataset.gaIfBound = "1"; table.addEventListener("input", e => { if (!gaScenario || !gaImagineIf) return; const t = e.target; const tr = t.closest("tr"); if (!tr) return; if (t.matches("[data-ga-if-score],[data-ga-if-pts],[data-ga-if-title]")) { const a = gaScenario.assignments.find(x => String(x.key) === String(tr.dataset.gaIfKey)); if (!a) return; if (t.matches("[data-ga-if-title]")) a.title = t.value; else if (t.matches("[data-ga-if-score]")) { const v = parseFloat(t.value); a.score = t.value.trim() !== "" && isFinite(v) ? v : null; } else { const v = parseFloat(t.value); a.points = t.value.trim() !== "" && isFinite(v) ? v : null; } gaApplyImagineTotal(); } else if (t.matches("[data-ga-if-gname],[data-ga-if-gweight]")) { const g = gaScenario.groups.find(x => String(x.gid) === String(tr.dataset.gaIfGid)); if (!g) return; if (t.matches("[data-ga-if-gname]")) g.name = t.value; else { const w = parseFloat(t.value); g.weight = isFinite(w) ? w : 0; } gaApplyImagineTotal(); } }); table.addEventListener("change", e => { if (!gaScenario || !gaImagineIf) return; const t = e.target; if (!t.matches("select[data-ga-if-groupsel]")) return; const tr = t.closest("tr"); const a = gaScenario.assignments.find(x => String(x.key) === String(tr?.dataset.gaIfKey)); if (a) { a.gid = t.value; gaApplyImagineTotal(); } }); table.addEventListener("click", e => { if (!gaScenario || !gaImagineIf) return; const btn = e.target.closest("button"); if (!btn) return; const table = btn.closest("table") || document.querySelector("#grades_summary"); if (btn.matches("[data-ga-if-adel]")) { const tr = btn.closest("tr"); const a = gaScenario.assignments.find(x => String(x.key) === String(tr.dataset.gaIfKey)); if (!a) return; a.deleted = true; if (a._new) tr.remove(); else { tr.style.display = "none"; tr.dataset.gaIfDeleted = "1"; } gaApplyImagineTotal(); } else if (btn.matches("[data-ga-if-gdel]")) { const tr = btn.closest("tr"); const g = gaScenario.groups.find(x => String(x.gid) === String(tr.dataset.gaIfGid)); if (!g) return; g.deleted = true; if (g._new) tr.remove(); else { tr.style.display = "none"; tr.dataset.gaIfDeleted = "1"; } gaIfRefreshSelects(table); gaApplyImagineTotal(); } else if (btn.id === "canvasrefined-ga-if-add-asg") { const groups = gaScenario.groups.filter(g => !g.deleted); const a = { key: "new-asg-" + (++gaIfCounter), title: "New assignment", score: null, points: 100, gid: groups[0] ? String(groups[0].gid) : "", _new: true }; gaScenario.assignments.push(a); const tr = gaIfMakeAssignmentRow(a); gaIfInsertAssignmentRow(table, tr); tr.dataset.gaIfKey = a.key; gaIfBuildAssignmentControls(tr, a); tr.scrollIntoView({ block: "nearest" }); tr.querySelector("[data-ga-if-title]")?.focus(); gaApplyImagineTotal(); } else if (btn.id === "canvasrefined-ga-if-add-group") { const g = { gid: "new-group-" + (++gaIfCounter), name: "New group", weight: 0, _new: true }; gaScenario.groups.push(g); const tr = gaIfMakeGroupRow(g); gaIfInsertGroupRow(table, tr); tr.dataset.gaIfGid = g.gid; gaIfBuildGroupControls(tr, g); gaIfRefreshSelects(table); tr.scrollIntoView({ block: "nearest" }); tr.querySelector("[data-ga-if-gname]")?.focus(); gaApplyImagineTotal(); } }); } // Builds the inline UI on the grades table itself. The scenario is rebuilt // from the page when the table's rows change (grading-period switch, SPA // navigation); otherwise the already-built UI is left alone so typing is // never clobbered. If Canvas wiped our controls (row re-render), the built // count no longer matches and the UI is rebuilt from the scenario. function gaRenderImagineIf(force) { if (!gaImagineIf) return; const table = document.querySelector("#grades_summary"); if (!table) return; const sig = gaImagineTableSig(); if (!gaScenario || gaScenario.sig !== sig) { const sc = gaBuildImagineScenario(); if (!sc) return; // table hasn't rendered yet; the DOM observer retries gaScenario = { sig, groups: sc.groups, assignments: sc.assignments }; force = true; } if (!force && table.dataset.gaIfUi === sig && Number(table.dataset.gaIfBuilt || 0) === table.querySelectorAll("tr[data-ga-if-key]").length) return; gaClearImagineUI(table); table.dataset.gaIfUi = sig; gaIfBindTable(table); const rowById = new Map(); for (const tr of table.querySelectorAll("tr.student_assignment")) { if (tr.classList.contains("group_total") || tr.classList.contains("final_grade")) continue; if (tr.id) rowById.set(tr.id, tr); } const groupRowByGid = new Map(); for (const tr of table.querySelectorAll("tr.group_total")) { const gid = (tr.querySelector(".assignment_group_id")?.textContent || "").trim(); if (gid) groupRowByGid.set(gid, tr); } let built = 0; for (const a of gaScenario.assignments) { let tr = a._new ? table.querySelector(`tr[data-ga-if-key="${CSS.escape(a.key)}"]`) : rowById.get(a.key); if (a.deleted) { if (tr) { tr.style.display = "none"; tr.dataset.gaIfDeleted = "1"; } continue; } if (!tr) { if (!a._new) continue; // the page row vanished — nothing to edit tr = gaIfMakeAssignmentRow(a); gaIfInsertAssignmentRow(table, tr); } tr.dataset.gaIfKey = a.key; gaIfBuildAssignmentControls(tr, a); built++; } for (const g of gaScenario.groups) { let tr = g._new ? table.querySelector(`tr[data-ga-if-gid="${CSS.escape(g.gid)}"]`) : groupRowByGid.get(g.gid); if (g.deleted) { if (tr) { tr.style.display = "none"; tr.dataset.gaIfDeleted = "1"; } continue; } if (!tr) { if (!g._new) continue; tr = gaIfMakeGroupRow(g); gaIfInsertGroupRow(table, tr); } tr.dataset.gaIfGid = g.gid; gaIfBuildGroupControls(tr, g); } gaIfEnsureAddAssignmentRow(table); gaIfEnsureAddGroupRow(table); table.dataset.gaIfBuilt = String(built); gaApplyImagineTotal(); } // Enters/leaves the mode: builds/removes the inline table UI and // overwrites/restores the sidebar Total block. function gaEnterImagineIf() { gaRenderImagineIf(); gaApplyImagineTotal(); } function gaExitImagineIf() { gaClearImagineUI(); gaRestoreImagineTotal(); } function getApiData() { if (current_page === "/" || current_page === "" || options.better_todo || options.better_sidebar) { getAssignments(); getGrades(); getColors(); } } function makeElement(element, location, options, prepend = false) { let creation = document.createElement(element); Object.keys(options).forEach(key => { creation[key] = options[key]; }); if (prepend) { location.insertBefore(creation, location.firstChild); } else { location.appendChild(creation); } return creation } async function getData(url) { let response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Fail loudly on HTTP errors (e.g. an expired session returning 401 or a // redirect to the login page). Previously non-OK responses were returned // as-is, so callers received an error object/HTML and crashed later in // confusing ways (or silently rendered wrong data). if (!response.ok) { throw new Error(`Canvas API request failed (${response.status})`); } let data = await response.json(); // Deep-clone via JSON to unwrap Firefox Xray objects so nested props are mutable. try { return JSON.parse(JSON.stringify(data)); } catch (_) { return data; } } function rgbToHex(rgb) { try { let pat = /^rgb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/; let exec = pat.exec(rgb); return "#" + parseInt(exec[1]).toString(16).padStart(2, "0") + parseInt(exec[2]).toString(16).padStart(2, "0") + parseInt(exec[3]).toString(16).padStart(2, "0"); } catch (e) { console.warn(e); } } function rgbToHsl(r, g, b) { r /= 255, g /= 255, b /= 255; var max = Math.max(r, g, b), min = Math.min(r, g, b); var h, s, l = (max + min) / 2; if (max == min) { h = s = 0; } else { var d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h * 360, s * 100, l * 100]; } function getRelativeDate(date, short = false) { let now = new Date(); let timeSince = (now.getTime() - date.getTime()) / 60000; let time = "min"; timeSince = Math.abs(timeSince); if (timeSince >= 60) { timeSince /= 60; time = short ? "h" : "hour"; if (timeSince >= 24) { timeSince /= 24; time = short ? "d" : "day"; if (timeSince >= 7) { timeSince /= 7; time = short ? "w" : "week"; } } } timeSince = Math.round(timeSince); let relative = timeSince + (short ? "" : " ") + time + (timeSince > 1 && !short ? "s" : ""); return { time: relative, ms: now.getTime() - date.getTime() }; } const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function formatTodoDate(date, submissions, hr24) { let { time, ms } = getRelativeDate(date); let fromNow = ms < 0 ? "in " + time : time + " ago"; let dueSoon = false; if (submissions && submissions.submitted === false && ms >= -21600000) { dueSoon = true; } return { "dueSoon": dueSoon, "date": months[date.getMonth()] + " " + date.getDate() + " at " + (date.getHours() - (hr24 ? "" : date.getHours() > 12 ? 12 : 0)) + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes() + (hr24 ? "" : date.getHours() >= 12 ? "pm" : "am") + " (" + fromNow + ")" }; } function formatCardDue(date) { let due = new Date(date); if (options.relative_dues === true) { let relative = getRelativeDate(due, true); return relative.ms > 0 ? relative.time + " ago" : "in " + relative.time; } return options.assignment_date_format ? (due.getDate()) + "/" + (due.getMonth() + 1) : (due.getMonth() + 1) + "/" + (due.getDate()); } function logError(e) { chrome.storage.local.get("errors", storage => { if (storage.errors.length > 20) { storage["errors"] = []; } chrome.storage.local.set({ "errors": storage["errors"].concat(e.stack) }); console.log(e.stack); console.log(storage["errors"].concat(e.stack)); }) } const CSRFtoken = function () { return decodeURIComponent((document.cookie.match('(^|;) *_csrf_token=([^;]*)') || '')[2]) }