diff --git a/js/content.js b/js/content.js
index efab257..d05601e 100644
--- a/js/content.js
+++ b/js/content.js
@@ -6763,18 +6763,6 @@ 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
@@ -6803,7 +6791,10 @@ 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 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
@@ -6864,11 +6855,10 @@ 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), getGradeAnalyticsImagineIf()]).then(([open, fit, calc, imagine]) => {
+ Promise.all([getGradeAnalyticsOpenState(), getGradeAnalyticsFitY(), getGaCalcSettings(courseId)]).then(([open, fit, calc]) => {
gaOpen = open;
gaFitY = fit;
gaCalc = calc;
- gaImagineIf = imagine;
const panel = ensureGradeAnalyticsPanel();
if (panel) applyGaCalcState(panel);
if (gaOpen && gaData) renderGradeAnalytics();
@@ -6889,6 +6879,13 @@ 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
@@ -6905,6 +6902,10 @@ function syncGradeAnalyticsUI() {
function removeGradeAnalyticsPanel() {
gaOpen = false;
+ // Never leave a hypothetical Total or inline editors behind when the
+ // panel goes away.
+ gaClearImagineUI();
+ gaRestoreImagineTotal();
document.getElementById("canvasrefined-grade-analytics")?.remove();
}
@@ -6922,8 +6923,8 @@ 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
+// 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");
@@ -7020,7 +7021,7 @@ function ensureGradeAnalyticsPanel() {
100%
- no graded work due
+ No grade/assignment
@@ -7038,13 +7039,13 @@ function ensureGradeAnalyticsPanel() {
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
+ // 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;
- setGradeAnalyticsImagineIf(gaImagineIf);
applyGradeAnalyticsImagineState(panel);
- if (gaImagineIf && gaOpen && gaData) renderGradeAnalytics();
+ 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.
@@ -7899,6 +7900,581 @@ window.addEventListener("resize", () => {
if (panel && gaOpen && gaData) renderGradeAnalytics();
});
+// --- Imagine-If mode ---------------------------------------------------------
+//
+// A hypothetical-grade sandbox rendered directly on the grades table (never
+// inside the Grade Analytics panel): each assignment row gets inline
+// score / points-possible inputs plus a group selector and remove button,
+// each group-total row gets a weight input and remove button, the "+ Add
+// assignment" button sits at the top of the table (above the first
+// assignment), and the "+ Add group" button sits above the Total row. The
+// hypothetical total is computed here — Canvas's weighting algorithm
+// (groups with counted work contribute pct × weight, scaled up to 100% when
+// the used weights total less than 100; point-based totals when no group has
+// weight) — and previewed by overwriting the table's Total row (the
+// tr.final_grade percentage span) with an obvious Imagine-If badge. Nothing is ever sent to Canvas; the page is
+// restored to the pixel the moment the mode is turned off.
+
+// Standard grading scheme for the hypothetical letter grade (a course's
+// actual scheme isn't exposed on this page). 80.5% → B− matches Canvas's
+// default cutoffs.
+const GA_LETTER_SCALE = [
+ ["A", 93], ["A−", 90], ["B+", 87], ["B", 83], ["B−", 80],
+ ["C+", 77], ["C", 73], ["C−", 70], ["D+", 67], ["D", 63], ["D−", 60],
+];
+
+function gaLetterFor(pct) {
+ if (pct == null || !isFinite(pct)) return null;
+ for (const [letter, min] of GA_LETTER_SCALE) if (pct >= min) return letter;
+ return "F";
+}
+
+// The table's Total row (tr.final_grade) — the recalculated grade
+// overwrites its percentage span while Imagine-If mode is on.
+function gaFindTotalGradeSpan() {
+ const row = document.querySelector("#grades_summary tr.final_grade");
+ return row ? row.querySelector("td.assignment_score .tooltip .grade") : null;
+}
+
+function gaFindTotalTitleCell() {
+ const row = document.querySelector("#grades_summary tr.final_grade");
+ return row ? row.querySelector("th.title") : null;
+}
+
+// Signature of the page's grades table (row ids). When it changes (grading
+// period switch, SPA navigation) the scenario is rebuilt from the new table.
+function gaImagineTableSig() {
+ const table = document.querySelector("#grades_summary");
+ return table ? [...table.querySelectorAll("tr.student_assignment")].map(tr => tr.id).join(",") : "";
+}
+
+// Snapshot of the page's groups and assignments the scenario starts from.
+// Scores come from the hidden "original_score" spans (immune to Canvas's own
+// What-If edits); ungraded rows keep score: null so they only count once the
+// user types a score for them.
+function gaBuildImagineScenario() {
+ const table = document.querySelector("#grades_summary");
+ if (!table) return null;
+ const groups = [];
+ for (const tr of table.querySelectorAll("tr.group_total")) {
+ const gid = (tr.querySelector(".assignment_group_id")?.textContent || "").trim();
+ if (!gid) continue;
+ const name = (tr.querySelector("th.title")?.textContent || "").trim() || ("Group " + gid);
+ const w = parseFloat((tr.querySelector(".group_weight")?.textContent || "").trim());
+ groups.push({ gid, name, weight: isFinite(w) ? w : 0 });
+ }
+ const assignments = [];
+ for (const tr of table.querySelectorAll("tr.student_assignment")) {
+ if (tr.classList.contains("group_total") || tr.classList.contains("final_grade")) continue;
+ const a = gaParseAssignmentRow(tr);
+ assignments.push({
+ key: tr.id || ("row-" + assignments.length),
+ title: (a.title || "Assignment").trim() || "Assignment",
+ score: a.score,
+ points: a.points,
+ gid: (a.gid || "").trim(),
+ });
+ }
+ return { groups, assignments };
+}
+
+// Hypothetical total for the scenario, computed from scratch (never read
+// from the page's Total). Only assignments with a score, a positive
+// denominator, and a group that still exists count. Weighted when any
+// group has weight (Canvas's GradeCalculator: sum pct × weight over groups
+// with counted work, scale up to 100% when the used weights total < 100,
+// use raw when ≥ 100), otherwise points earned / points possible.
+function gaComputeImagineTotal() {
+ if (!gaScenario) return null;
+ const groups = new Map();
+ let totalWeightAll = 0;
+ for (const g of gaScenario.groups) {
+ if (g.deleted) continue;
+ const w = isFinite(g.weight) ? g.weight : 0;
+ groups.set(String(g.gid), w);
+ totalWeightAll += w;
+ }
+ // With every group removed the total falls back to plain points, so all
+ // assignments count regardless of their (dangling) group id.
+ const noGroups = groups.size === 0;
+ const stats = new Map(); // gid -> {score, pts}
+ for (const a of gaScenario.assignments) {
+ if (a.deleted || a.score == null || !(a.points > 0)) continue;
+ const gid = String(a.gid ?? "");
+ if (!noGroups && !groups.has(gid)) continue; // group deleted / unassigned
+ const s = stats.get(gid) || { score: 0, pts: 0 };
+ s.score += a.score;
+ s.pts += a.points;
+ stats.set(gid, s);
+ }
+ if (!stats.size) return null;
+ if (totalWeightAll > 0) {
+ let weighted = 0, used = 0;
+ for (const [gid, s] of stats) {
+ const w = groups.get(gid) || 0;
+ weighted += (s.score / s.pts) * w;
+ used += w;
+ }
+ if (used > 0) return used < 100 ? (weighted / used) * 100 : weighted;
+ // Only zero-weighted groups have counted work — fall back to points.
+ }
+ let score = 0, pts = 0;
+ for (const s of stats.values()) { score += s.score; pts += s.pts; }
+ return pts > 0 ? (score / pts) * 100 : null;
+}
+
+// Total-row content for the hypothetical grade — clearly not the real
+// grade: a purple Imagine-If badge and the hypothetical total (with letter)
+// replace the percentage span, and an italic note under the "Total" label
+// restates the real grade.
+function gaImagineTotalHtml(pct) {
+ const letter = gaLetterFor(pct);
+ const pctText = pct == null || !isFinite(pct) ? "—" : pct.toFixed(1) + "%";
+ // The wrapper is an inline-flex row with align-items:center so the
+ // badge pill sits vertically centered with the grade text. No
+ // flex-wrap: the narrow score cell would stack the items instead.
+ return ``
+ + `Imagine-If`
+ + `${pctText}`
+ + (letter ? `(${letter})` : "")
+ + ``;
+}
+
+
+// Overwrites (or restores) the table's Total row. Writes are guarded so the
+// GA DOM observer never loops: the last written HTML is cached on the
+// element and identical writes are skipped.
+function gaApplyImagineTotal() {
+ if (!gaImagineIf) { gaRestoreImagineTotal(); return; }
+ gaIfUpdateGroupPcts();
+ const span = gaFindTotalGradeSpan();
+ if (!span) return;
+ const html = gaImagineTotalHtml(gaComputeImagineTotal());
+ if (!(span.dataset.gaImagine && span._gaImagineHtml === html)) {
+ if (!span.dataset.gaImagine) gaOriginalFinalHtml = span.innerHTML;
+ span.dataset.gaImagine = "1";
+ span._gaImagineHtml = html;
+ span.innerHTML = html;
+ }
+ // Italic note under the "Total" label restating the real grade.
+ const th = gaFindTotalTitleCell();
+ if (th) {
+ let note = th.querySelector(".canvasrefined-ga-if-note");
+ if (!note) {
+ note = document.createElement("div");
+ note.className = "canvasrefined-ga-if-note";
+ note.style.cssText = "font-size:11px;font-style:italic;color:var(--bctext-1);margin-top:2px;";
+ th.appendChild(note);
+ }
+ if (note.dataset.gaIfHtml !== "Imagine-If scenario; not your actual grade.") {
+ note.dataset.gaIfHtml = "Imagine-If scenario; not your actual grade.";
+ note.textContent = "Imagine-If scenario; not your actual grade.";
+ }
+ }
+}
+
+function gaRestoreImagineTotal() {
+ const span = gaFindTotalGradeSpan();
+ if (span && span.dataset.gaImagine) {
+ delete span.dataset.gaImagine;
+ delete span._gaImagineHtml;
+ if (gaOriginalFinalHtml != null) span.innerHTML = gaOriginalFinalHtml;
+ }
+ document.querySelectorAll("#grades_summary tr.final_grade .canvasrefined-ga-if-note").forEach(n => n.remove());
+}
+
+// Compact inline styles for controls injected into the grades table.
+// Canvas's table CSS gives selects/inputs an 11px bottom margin and a tall
+// native select box, which made our rows taller than the page's own —
+// margin:0 and a fixed select height keep the rows even.
+const GA_IF_TD_INPUT = "box-sizing:border-box;padding:3px 6px;border-radius:5px;border:1px solid var(--bcborders);background:var(--bcbackground-1);color:var(--bctext-0);font-size:12px;margin:0;";
+const GA_IF_TD_NUM = GA_IF_TD_INPUT + "width:58px;";
+const GA_IF_TD_SEL = GA_IF_TD_INPUT + "max-width:170px;height:26px;padding:2px 4px;";
+const GA_IF_TD_BTN = GA_IF_TD_INPUT + "cursor:pointer;white-space:nowrap;";
+
+//