Max items to show:
diff --git a/js/background.js b/js/background.js
index 816fe2b..d144bba 100644
--- a/js/background.js
+++ b/js/background.js
@@ -86,6 +86,7 @@ chrome.runtime.onInstalled.addListener(function () {
"todo_full_height": true,
"todo_progress_rings": "rings",
"todo_confetti": true,
+ "todo_show_scores": false,
"device_dark": false,
"cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 },
// "show_updates": false,
diff --git a/js/content.js b/js/content.js
index f40ab20..f8078e1 100644
--- a/js/content.js
+++ b/js/content.js
@@ -955,6 +955,7 @@ function applyOptionsChanges(changes) {
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;
@@ -3754,6 +3755,110 @@ function attachTodoHoverPreview(anchor, item) {
const TODO_QUIZ_ICON_SVG = '';
const TODO_DISCUSSION_ICON_SVG = '';
+// --- Assignment scores on the Better Todo List ("Show assignment scores") ---
+// Canvas's planner items API omits the actual submission score (its
+// `submissions` object only carries booleans like graded/late), so points
+// possible come straight from the plannable and the earned score is lazily
+// fetched per course (students/submissions?student_ids[]=self) and cached for
+// the session. Badges always render as "{score}/{points} pts" — an en dash
+// stands in for the earned score until the fetch lands (or while the item is
+// ungraded/unsubmitted), and "Excused" replaces the whole badge for excused
+// submissions.
+const todoScoreCache = new Map(); // `${courseId}:${plannableId}` -> {score, excused}
+let todoScoreLoadToken = 0;
+
+function formatTodoScoreNumber(n) {
+ return String(Math.round(n * 100) / 100);
+}
+
+// Tiny green check shown right after the score on graded assignments. Kept
+// as a constant string so both the initial render and the async patch can
+// insert the exact same markup.
+const TODO_GRADED_BADGE_HTML = ``;
+
+// Small inline badge shown next to the due date. "" when the item has no
+// meaningful points (custom tasks, wiki pages, etc.).
+function todoScoreBadgeHtml(item) {
+ if (item.plannable_type == "planner_note" || item.planner_override?.custom === true) return "";
+ const points = item.plannable?.points_possible;
+ if (points == null) return "";
+ const pts = formatTodoScoreNumber(points);
+ const key = `${item.course_id}:${item.plannable_id}`;
+ const cached = todoScoreCache.get(key);
+ // Graded state: the live fetched score is the strongest signal, otherwise
+ // fall back to the planner API's boolean — but only while the score fetch
+ // hasn't run yet (a cached null score means the fetch confirmed no grade).
+ const graded = cached ? cached.score != null : item.submissions?.graded === true;
+ let text;
+ if (cached?.excused) text = "Excused";
+ else text = `${cached?.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${pts} pts`;
+ const attrs = `data-todo-course="${item.course_id ?? ""}" data-todo-plannable="${item.plannable_id}" data-todo-points="${pts}"`;
+ return `${text}${graded ? TODO_GRADED_BADGE_HTML : ""}`;
+}
+
+// Fetches the current user's submission scores for every course with visible
+// todo items still missing a score, then patches the badges in place (a full
+// re-render would reset the "View More" expansion state). The token guards
+// against overlapping loads from rapid option/tab switches: an outdated run
+// stops touching the DOM, and any newer run re-collects the still-missing
+// items anyway.
+async function loadTodoScores(items) {
+ if (options.todo_show_scores !== true) return;
+ const byCourse = new Map();
+ for (const item of items) {
+ if (item.plannable_type == "planner_note" || item.planner_override?.custom === true) continue;
+ if (item.plannable?.points_possible == null) continue;
+ const cid = item.course_id ?? item.context_id;
+ if (cid == null) continue;
+ const key = `${cid}:${item.plannable_id}`;
+ if (todoScoreCache.has(key)) continue;
+ if (item.submissions?.excused === true) { todoScoreCache.set(key, { score: null, excused: true }); continue; }
+ let list = byCourse.get(String(cid));
+ if (!list) { list = []; byCourse.set(String(cid), list); }
+ list.push(item);
+ }
+ if (byCourse.size === 0) return;
+ const token = ++todoScoreLoadToken;
+ for (const [cid] of byCourse) {
+ try {
+ let url = `${domain}/api/v1/courses/${cid}/students/submissions?student_ids[]=self&per_page=100`;
+ // 10 pages * 100 submissions is a safety net against a malformed
+ // next link, same as fetchActiveCourseIds.
+ for (let page = 0; page < 10 && url; page++) {
+ const response = await fetch(url, {
+ method: "GET",
+ headers: { "Content-Type": "application/json", "Accept": "application/json" }
+ });
+ if (!response.ok) throw new Error(`Canvas API request failed (${response.status})`);
+ const data = await response.json();
+ if (token !== todoScoreLoadToken) return;
+ if (Array.isArray(data)) {
+ for (const s of data) {
+ if (s && s.assignment_id != null) {
+ todoScoreCache.set(`${cid}:${s.assignment_id}`, { score: s.score, excused: s.excused === true });
+ }
+ }
+ }
+ url = getNextPageUrl(response.headers.get("Link"));
+ }
+ } catch (e) {
+ // Leave these items uncached so a later render retries the fetch
+ // (e.g. teacher accounts, which the submissions endpoint rejects).
+ }
+ }
+ if (token !== todoScoreLoadToken) return;
+ // Patch every badge that now has data, on either tab.
+ document.querySelectorAll("#better-todo-main .better-todo-score").forEach(el => {
+ const cached = todoScoreCache.get(`${el.dataset.todoCourse}:${el.dataset.todoPlannable}`);
+ if (!cached) return;
+ if (cached.excused) el.textContent = "Excused";
+ else el.textContent = `${cached.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${el.dataset.todoPoints} pts`;
+ if (cached.score != null && !el.nextElementSibling?.classList.contains("better-todo-graded")) {
+ el.insertAdjacentHTML("afterend", TODO_GRADED_BADGE_HTML);
+ }
+ });
+}
+
function populateAssignments(iscompleted = false) {
const today = new Date();
today.setHours(0,0,0,0);
@@ -3858,6 +3963,7 @@ function populateAssignments(iscompleted = false) {
// 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
? `