diff --git a/js/content.js b/js/content.js
index 931deb1..a9147ac 100644
--- a/js/content.js
+++ b/js/content.js
@@ -6640,25 +6640,27 @@ function getAssignments() {
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: "#a3e635" },
- { label: "60-69", min: 60, max: 70, color: "#facc15" },
- { label: "50-59", min: 50, max: 60, color: "#fb923c" },
- { label: "40-49", min: 40, max: 50, color: "#f87171" },
- { label: "30-39", min: 30, max: 40, color: "#ef4444" },
- { label: "20-29", min: 20, max: 30, color: "#dc2626" },
- { label: "10-19", min: 10, max: 20, color: "#b91c1c" },
+ { 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.
+// 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 + 2.5; // band midpoint
+ 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;
@@ -6688,9 +6690,36 @@ 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 gaTab = "overview"; // active panel tab: "overview" | "calc"
+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;
@@ -6737,6 +6766,7 @@ function watchGradeAnalytics() {
if (gaCourseId !== null && gaCourseId !== courseId) {
gaData = null;
gaCourseId = null;
+ gaCalc = null; // per-course final-calculator settings
removeGradeAnalyticsPanel();
}
if (!gaObserver) {
@@ -6748,10 +6778,12 @@ 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()]).then(([open, fit]) => {
+ Promise.all([getGradeAnalyticsOpenState(), getGradeAnalyticsFitY(), getGaCalcSettings(courseId)]).then(([open, fit, calc]) => {
gaOpen = open;
gaFitY = fit;
- ensureGradeAnalyticsPanel();
+ gaCalc = calc;
+ const panel = ensureGradeAnalyticsPanel();
+ if (panel) applyGaCalcState(panel);
if (gaOpen && gaData) renderGradeAnalytics();
});
if (!gaData && !gaLoading) loadGradeAnalytics();
@@ -6838,6 +6870,11 @@ function ensureGradeAnalyticsPanel() {
Loading grade data…
+
+
+
+
+
@@ -6853,6 +6890,22 @@ function ensureGradeAnalyticsPanel() {
+
+
+
Enter how much your final is worth and the overall grade you want to show see what grade you need on the final.
+
+
+
+
+
+
+
+
+
`;
const toggleBtn = panel.querySelector("#canvasrefined-ga-toggle");
const fitYCheckbox = panel.querySelector("#canvasrefined-ga-fity");
@@ -6873,6 +6926,30 @@ function ensureGradeAnalyticsPanel() {
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);
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
@@ -7038,13 +7115,14 @@ function computeGradeAnalyticsFromPage(table) {
};
}
-function renderGradeAnalytics() {
+// 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 status = panel.querySelector("#canvasrefined-ga-status");
- if (status) status.style.display = "none";
-
const stats = panel.querySelector("#canvasrefined-ga-stats");
+ if (!stats) return;
stats.style.display = "flex";
const stat = (cap, val, color) =>
`
${val}
${cap}
`;
@@ -7056,11 +7134,33 @@ function renderGradeAnalytics() {
else if (gaData.trend < -0.05) { trendVal = "\u25BC " + gaData.trend.toFixed(1) + "%"; trendColor = "#dc2626"; }
else { trendVal = "\u25BA " + gaData.trend.toFixed(1) + "%"; 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(1) + "%"; color = gaNeededColor(needed); }
+ goalStat = stat("Grade goal", val, color);
+ }
stats.innerHTML =
stat("Overall grade", gaData.current == null ? "-" : gaData.current.toFixed(1) + "%") +
stat("Grade trend (last 5)", trendVal, trendColor) +
stat("Graded", gaData.graded) +
- stat("Ungraded", gaData.ungraded);
+ 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";
@@ -7069,6 +7169,110 @@ function renderGradeAnalytics() {
gaDrawPie(panel.querySelector("#canvasrefined-ga-pie"), panel.querySelector("#canvasrefined-ga-pie-tip"));
gaDrawLine(panel.querySelector("#canvasrefined-ga-line"), panel.querySelector("#canvasrefined-ga-line-tip"));
+ 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;
+ const overview = panel.querySelector("#canvasrefined-ga-tab-overview");
+ const calc = panel.querySelector("#canvasrefined-ga-tab-calc");
+ if (overview) overview.style.display = tab === "overview" ? "" : "none";
+ if (calc) calc.style.display = tab === "calc" ? "" : "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 {
+ 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 = `Enter your final's weight and your target grade to see what you need on the final.`;
+ return;
+ }
+ const w = gaCalc.weight / 100;
+ const target = gaCalc.target;
+ const current = gaData ? gaData.current : null;
+ if (current == null) {
+ box.innerHTML = `Waiting for grade data…`;
+ 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 = `You're already there!`;
+ sub = `Even a 0 on the final leaves you at ${withZero.toFixed(1)}%, which is above your ${target}% goal.`;
+ } else if (needed > 100) {
+ head = `Out of reach`;
+ sub = `Even a perfect final only gets you to ${withPerfect.toFixed(1)}%, which is below your ${target}% goal.`;
+ } else {
+ head = `You need ≥ ${needed.toFixed(1)}% on the final`;
+ sub = `You got this!`;
+ }
+ box.innerHTML = head + `