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…

+
+ + +
+
+ +
`; 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 + `
${sub}
`; } // --- Canvas-drawn charts --------------------------------------------------