more work on cool feature 2

This commit is contained in:
Guy Sandler 2026-08-29 13:18:06 -07:00
parent 462186b1b7
commit 4184b717e8

View File

@ -6366,37 +6366,29 @@ function gradeAnalyticsActive() {
return options.grade_analytics === true && isGradesPage() && !quizSafeModeActive(); return options.grade_analytics === true && isGradesPage() && !quizSafeModeActive();
} }
// Paginated Canvas API GET using the user's session. Unwraps Firefox Xray // Grades data is read straight from the #grades_summary table the page
// proxies like getData() does. The first page is fetched serially to learn the // already rendered — no API round trips, so even courses with hundreds of
// total page count from its Link header; every remaining page is then fetched // assignments populate instantly, and the numbers always match what the user
// concurrently, so courses with many assignments don't pay one round trip per // sees (grading periods, unposted grades, etc.). Waits briefly for the table
// page. // to appear on SPA navigations.
async function gaFetchAll(path) { function gaWaitForGradesTable(timeoutMs = 15000) {
const fetchPage = async (url) => { return new Promise((resolve, reject) => {
const res = await fetch(url, { headers: { Accept: "application/json" } }); const ready = () => {
if (!res.ok) throw new Error("Canvas API " + res.status + " for " + url.pathname); const table = document.querySelector("#grades_summary");
return { data: JSON.parse(JSON.stringify(await res.json())), link: res.headers.get("Link") || "" }; return table && table.querySelector("tr.student_assignment") ? table : null;
}; };
const firstUrl = new URL(path, domain); const found = ready();
firstUrl.searchParams.set("per_page", "100"); if (found) { resolve(found); return; }
const { data: first, link } = await fetchPage(firstUrl); const started = Date.now();
const out = Array.isArray(first) ? first.slice() : []; const timer = setInterval(() => {
const lastMatch = link.match(/<([^>]+)>;\s*rel="last"/); const table = ready();
if (lastMatch) { if (table) { clearInterval(timer); resolve(table); }
const lastPage = parseInt(new URL(lastMatch[1]).searchParams.get("page") || "1", 10); else if (Date.now() - started > timeoutMs) {
if (lastPage > 1) { clearInterval(timer);
const pageUrls = []; reject(new Error("grades table not found on this page"));
for (let p = 2; p <= lastPage; p++) {
const u = new URL(path, domain);
u.searchParams.set("per_page", "100");
u.searchParams.set("page", String(p));
pageUrls.push(u);
} }
const rest = await Promise.all(pageUrls.map(u => fetchPage(u).then(r => r.data))); }, 250);
for (const page of rest) if (Array.isArray(page)) out.push(...page); });
}
}
return out;
} }
// Entry point: called at init, on SPA navigation, and when the option changes. // Entry point: called at init, on SPA navigation, and when the option changes.
@ -6561,18 +6553,18 @@ async function loadGradeAnalytics() {
if (courseId == null) return; if (courseId == null) return;
gaLoading = true; gaLoading = true;
const status = document.getElementById("canvasrefined-ga-status"); const status = document.getElementById("canvasrefined-ga-status");
if (status) status.textContent = "Loading grade data…"; if (status) status.textContent = "Reading grade data…";
try { try {
const [assignments, groups] = await Promise.all([ // Parse the grades table the page already rendered instead of hitting
gaFetchAll(`/api/v1/courses/${courseId}/assignments?include[]=submission&order_by=due_at`), // the paginated API — instant even for courses with hundreds of
gaFetchAll(`/api/v1/courses/${courseId}/assignment_groups`), // assignments, and always in sync with what the page shows.
]); const table = await gaWaitForGradesTable();
gaCourseId = courseId; gaCourseId = courseId;
gaData = computeGradeAnalytics(assignments, groups); gaData = computeGradeAnalyticsFromPage(table);
gaLoading = false; gaLoading = false;
// renderGradeAnalytics self-guards on panel existence and canvas // renderGradeAnalytics self-guards on panel existence and canvas
// size; if the panel isn't ready yet, the retry below in // size; if the panel isn't ready yet, the retry in
// ensureGradeAnalyticsPanel draws once it is. // ensureGradeAnalyticsPanel / syncGradeAnalyticsUI draws once it is.
renderGradeAnalytics(); renderGradeAnalytics();
} catch (err) { } catch (err) {
logError(err); logError(err);
@ -6583,74 +6575,117 @@ async function loadGradeAnalytics() {
} }
} }
function gaIsGraded(a) { // Parses one assignment row of #grades_summary into a plain record. The row
return a.published !== false && a.points_possible > 0 && // stashes the original posted score in a hidden "original_score" span (the
(a.submission_types || []).indexOf("not_graded") === -1 && // "original_points" span holds points EARNED, not possible), while points
a.submission && a.submission.score != null; // possible is only in the "/ 15" span displayed after the grade.
function gaParseNum(t) {
if (!t) return null;
let s = String(t).replace(/\s+/g, "");
if (s === "" || !/\d/.test(s)) return null;
// Normalize "1,234.5" (thousands grouping) and "9,5" (comma decimal).
if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
else if (/^-?\d+,\d+$/.test(s)) s = s.replace(",", ".");
const v = parseFloat(s);
return isFinite(v) ? v : null;
} }
function gaIsUngraded(a) { function gaParseAssignmentRow(tr) {
// Graded-type assignment with points possible but no score yet. const q = (sel) => tr.querySelector(sel);
if (a.published === false || a.points_possible <= 0) return false; const titleLink = q(".title a");
if ((a.submission_types || []).includes("not_graded")) return false; const possibleText = q(".tooltip .grade + span")?.textContent || "";
return !(a.submission && a.submission.score != null); return {
title: (titleLink ? titleLink.textContent : (q("th.title")?.textContent || "")).trim(),
score: gaParseNum(q(".original_score")?.textContent),
points: gaParseNum(possibleText.replace(/^.*\//, "")),
// Rows the page lists as unsubmitted/unposted carry no score; only
// "graded" rows have one.
status: (q(".submission_status")?.textContent || "").trim(),
gid: (q(".assignment_group_id")?.textContent || "").trim(),
due: (q("td.due")?.textContent || "").replace(/\s+/g, " ").trim(),
};
} }
function computeGradeAnalytics(assignments, groups) { 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); const counts = GA_BUCKETS.map(() => 0);
let ungraded = 0, graded = 0; let ungraded = 0;
for (const a of assignments) { const graded = [];
if (gaIsUngraded(a)) { ungraded++; continue; } for (const a of rows) {
if (!gaIsGraded(a)) continue; if (a.points == null || a.points <= 0) continue; // no points possible
const pct = (a.submission.score / a.points_possible) * 100; 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); const idx = GA_BUCKETS.findIndex(b => pct >= b.min && pct < b.max);
counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++; counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++;
} }
const groupName = {}; // Running overall grade, in the page's row order, using Canvas's own
const weight = {}; // weighting algorithm (GradeCalculator): sum each group's pct × weight
for (const g of groups) { // over groups that have graded work, then scale up to 100% only when
groupName[g.id] = g.name; // those weights total less than 100 (weights over 100 are used raw and
weight[g.id] = g.group_weight || 0; // can push the grade past 100). Verified to reproduce the Total shown
} // on the page. Point-based courses (no group weights) use points
const totalWeight = Object.values(weight).reduce((s, w) => s + w, 0); // earned / points possible.
// Running weighted grade in due-date order, exactly like the user sees it
// accumulate over the term.
const timeline = assignments
.filter(gaIsGraded)
.sort((x, y) => new Date(x.due_at || 0) - new Date(y.due_at || 0));
const running = {}; const running = {};
const points = timeline.map(a => { const pointsGrade = () => {
const r = (running[a.assignment_group_id] ||= { score: 0, pts: 0 }); let s = 0, p = 0;
r.score += a.submission.score; for (const g of Object.values(running)) { s += g.score; p += g.pts; }
r.pts += a.points_possible; return p > 0 ? (s / p) * 100 : null;
let weighted = 0, used = 0; };
for (const gid of Object.keys(running)) { const points = graded.map(a => {
const g = running[gid]; const r = (running[a.gid] ||= { score: 0, pts: 0 });
if (g.pts <= 0) continue; r.score += a.score;
if (totalWeight > 0) { r.pts += a.points;
weighted += (g.score / g.pts) * (weight[gid] || 0); let grade = null;
used += (weight[gid] || 0); if (totalWeight > 0) {
} else { let weighted = 0, fullWeight = 0;
weighted += g.score; for (const gid of Object.keys(running)) {
used += g.pts; 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();
} }
const grade = used > 0 ? (weighted / used) * 100 : null;
const pct = (a.submission.score / a.points_possible) * 100;
return { return {
title: a.name, title: a.title,
score: a.submission.score, score: a.score,
points: a.points_possible, points: a.points,
pct, pct: (a.score / a.points) * 100,
grade, grade,
group: groupName[a.assignment_group_id] || "", due: a.due,
due: a.due_at ? new Date(a.due_at).toLocaleDateString() : "",
}; };
}); });
const pcts = timeline.map(a => (a.submission.score / a.points_possible) * 100); const pcts = graded.map(a => (a.score / a.points) * 100);
// Trend: change in the running overall grade over the last 5 graded // Trend: change in the running overall grade over the last 5 graded
// assignments (or since the first, if fewer). Positive = climbing. // assignments (or since the first, if fewer). Positive = climbing.
let trend = null; let trend = null;
@ -6662,9 +6697,9 @@ function computeGradeAnalytics(assignments, groups) {
return { return {
counts, counts,
ungraded, ungraded,
graded: pcts.length, graded: graded.length,
avg: pcts.length ? pcts.reduce((s, p) => s + p, 0) / pcts.length : null, avg: pcts.length ? pcts.reduce((s, p) => s + p, 0) / pcts.length : null,
current: points.length ? points[points.length - 1].grade : null, current: pageTotal != null ? pageTotal : (points.length ? points[points.length - 1].grade : null),
trend, trend,
points, points,
}; };
@ -6866,14 +6901,34 @@ function gaDrawLine(canvas, tooltip) {
ctx.fillStyle = "#2563eb"; ctx.fill(); ctx.fillStyle = "#2563eb"; ctx.fill();
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.stroke();
}); });
// X labels: first, middle, last due dates. // 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.textAlign = "center"; ctx.textBaseline = "top";
[[0], [Math.floor((pts.length - 1) / 2)], [pts.length - 1]].forEach(([i]) => { ctx.strokeStyle = "rgba(128,128,128,0.55)";
if (pts.length > 1 && pts.length > 2 && i !== 0 && i !== pts.length - 1 && Math.abs(X(i) - X(0)) < 40) return; ctx.lineWidth = 1;
const label = pts[i].due || ""; const tick = (x) => {
if (!label) return; 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)));
let prevM = null, absM = null;
let lastLabeled = 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;
if (absM === lastLabeled) return;
lastLabeled = absM;
tick(X(i));
// Anchor the edge labels inward so they don't clip.
ctx.textAlign = i === 0 ? "left" : (i === pts.length - 1 ? "right" : "center");
ctx.fillStyle = text; ctx.fillStyle = text;
ctx.fillText(label, X(i), h - pad.b + 6); ctx.fillText((p.due || "").trim().slice(0, 3), X(i), h - pad.b + 6);
}); });
// Hover indicator: dashed vertical guide plus a halo ring around the // Hover indicator: dashed vertical guide plus a halo ring around the
// hovered dot so it's obvious which point the tooltip describes. // hovered dot so it's obvious which point the tooltip describes.