diff --git a/js/content.js b/js/content.js index e7134ef..efab257 100644 --- a/js/content.js +++ b/js/content.js @@ -1915,6 +1915,17 @@ function applyTodoTimeframe(items) { 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. @@ -2491,7 +2502,7 @@ function renderProgressRings(container, scopedData) { // Apply the same timeframe filter the list uses so the counts in the // display match what's shown below it. - const allAssignments = applyTodoTimeframe(scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note"))); + const allAssignments = applyTodoTimeframe(scopedData.filter(item => isTodoTaskType(item))); const groups = {}; allAssignments.forEach(item => { @@ -3155,8 +3166,8 @@ async function createTodoSections(location) { }); announcements = displayData.filter(item => item.plannable_type == "announcement"); - assignmentsDue = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && !item.submissions?.submitted && !item.planner_override?.marked_complete); - completed = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete)); + assignmentsDue = displayData.filter(item => isTodoTaskType(item) && !item.submissions?.submitted && !item.planner_override?.marked_complete); + completed = displayData.filter(item => isTodoTaskType(item) && (item.submissions?.submitted || item.planner_override?.marked_complete)); // 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. Only the Tasks tab is affected (announcements and @@ -3445,6 +3456,13 @@ function attachTodoHoverPreview(anchor, item) { }); } +// 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 = ''; + function populateAssignments(iscompleted = false) { const today = new Date(); today.setHours(0,0,0,0); @@ -3535,12 +3553,12 @@ function populateAssignments(iscompleted = false) { ? ` ` - : ` + : (item.plannable_type == "quiz" ? TODO_QUIZ_ICON_SVG : item.plannable_type == "discussion_topic" ? TODO_DISCUSSION_ICON_SVG : ` - `; + `); assignment.style.overflowX = "hidden"; assignment.innerHTML = ` @@ -6622,13 +6640,67 @@ function changeFavicon() { function getAssignments() { if (options.assignments_due === true || options.better_todo === true) { - let weekAgo = new Date(new Date() - 604800000); - //let weekAgo = new Date(new Date() - (604800000 * 10)); - assignments = getData(`${domain}/api/v1/planner/items?start_date=${weekAgo.toISOString()}&per_page=75`); + // Fetch planner items from as far back as possible so overdue tasks + // always appear, no matter how long ago they were due. The planner + // API defaults start_date to "now" (which would hide every overdue + // item), so a far-past start date is required. Canvas returns planner + // items oldest-first in pages, so every page must be followed — a + // single request would only return the oldest page and silently drop + // all recent items. + assignments = getAllPlannerItems(); cardAssignments = preloadAssignmentEls(); } } +// Far-past start date for the planner items fetch. Concluded courses are +// excluded by the API by default, so this only pulls history from the user's +// currently active courses, which keeps the payload bounded. +const PLANNER_START_DATE = "2000-01-01"; +// 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; + +// Fetches every page of /api/v1/planner/items since PLANNER_START_DATE. +// Uses the same session/headers as getData but follows the Link "next" +// headers until exhausted. +async function getAllPlannerItems() { + const allItems = []; + let url = `${domain}/api/v1/planner/items?start_date=${PLANNER_START_DATE}&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) { + break; + } + if (!response.ok || !Array.isArray(data)) break; + // Deep-clone via JSON to unwrap Firefox Xray objects so nested props + // are mutable (same as getData). + try { + data = JSON.parse(JSON.stringify(data)); + } catch (_) { /* keep original */ } + allItems.push(...data); + url = getNextPageUrl(response.headers.get("Link")); + } + return allItems; +} + +// 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 @@ -6691,6 +6763,18 @@ function setGradeAnalyticsFitY(fit) { chrome.storage.local.set({ [GA_FIT_Y_KEY]: fit }); } +// "Imagine-If mode" toggle state, remembered across page loads. +const GA_IMAGINE_KEY = "grade_analytics_imagine_if"; + +async function getGradeAnalyticsImagineIf() { + const result = await chrome.storage.local.get(GA_IMAGINE_KEY); + return result[GA_IMAGINE_KEY] ?? false; +} + +function setGradeAnalyticsImagineIf(on) { + chrome.storage.local.set({ [GA_IMAGINE_KEY]: on }); +} + // 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 @@ -6719,6 +6803,7 @@ function saveGaCalcSettings() { 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 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 @@ -6779,10 +6864,11 @@ function watchGradeAnalytics() { 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]) => { + Promise.all([getGradeAnalyticsOpenState(), getGradeAnalyticsFitY(), getGaCalcSettings(courseId), getGradeAnalyticsImagineIf()]).then(([open, fit, calc, imagine]) => { gaOpen = open; gaFitY = fit; gaCalc = calc; + gaImagineIf = imagine; const panel = ensureGradeAnalyticsPanel(); if (panel) applyGaCalcState(panel); if (gaOpen && gaData) renderGradeAnalytics(); @@ -6836,6 +6922,18 @@ function applyGradeAnalyticsOpenState(panel) { btn.setAttribute("aria-expanded", String(gaOpen)); } +// Syncs the "Imagine-If mode" button's DOM to the in-memory (storage-backed) +// 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. @@ -6851,6 +6949,7 @@ function ensureGradeAnalyticsPanel() { // 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(); @@ -6867,7 +6966,8 @@ function ensureGradeAnalyticsPanel() { panel.innerHTML = `

Grade Analytics

- + +

Loading grade data…

@@ -6928,6 +7028,7 @@ function ensureGradeAnalyticsPanel() {
`; 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", () => { @@ -6936,6 +7037,15 @@ function ensureGradeAnalyticsPanel() { applyOpenState(); if (gaOpen && gaData) renderGradeAnalytics(); }); + // "Imagine-If mode" button on the right side of the panel header. The + // state is remembered across pages via chrome.storage; the active style + // highlights the button while the mode is on. + imagineBtn.addEventListener("click", () => { + gaImagineIf = !gaImagineIf; + setGradeAnalyticsImagineIf(gaImagineIf); + applyGradeAnalyticsImagineState(panel); + if (gaImagineIf && gaOpen && gaData) renderGradeAnalytics(); + }); // "Fit Y axis" scales the line chart's Y axis to the data instead of a // fixed 0-100; the choice is remembered across pages via chrome.storage. fitYCheckbox.checked = gaFitY; @@ -6970,6 +7080,7 @@ function ensureGradeAnalyticsPanel() { 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