Compare commits

...

3 Commits

Author SHA1 Message Date
Guy Sandler
47ce60bc0d more fixes 2026-08-29 14:43:39 -07:00
Guy Sandler
9ab7d16bd9 light mode fixes 2026-08-29 14:25:26 -07:00
Guy Sandler
4184b717e8 more work on cool feature 2 2026-08-29 13:18:06 -07:00
6 changed files with 313 additions and 132 deletions

View File

@ -315,6 +315,9 @@
"message": "Grade Analytics"
},
"grade_analytics_desc": {
"message": "Adds an Analytics toggle to the left side of course grade pages with a score-distribution chart and your overall grade over time."
"message": "Adds tools to help you analyze your grades."
},
"grade_analytics_zones": {
"message": "Colored grade zones"
}
}

View File

@ -366,3 +366,26 @@
padding: 0 4px; color: var(--bctext-1, #5b6770);
}
/* ===== Grades table: scale down with narrow windows =====
Canvas lays out #grades_summary with content-driven auto sizing plus hard
min-widths (title 150px, details 80px) and nowrap cells (due, score,
details), giving it a ~780px floor below which the whole table overflows
the page instead of shrinking. Switch it to fixed layout with percentage
column widths (set on the header row, which drives fixed layout) and let
tight cells wrap, so the header, body and rows all scale with the
viewport. #assignments scrolls horizontally as a last resort (e.g. while a
what-if score input is open). */
#grades_summary { table-layout: fixed; width: 100%; }
#grades_summary thead th:nth-child(1) { width: 30%; } /* Name */
#grades_summary thead th:nth-child(2) { width: 12%; } /* Due */
#grades_summary thead th:nth-child(3) { width: 10%; } /* Submitted */
#grades_summary thead th:nth-child(4) { width: 7%; } /* Status */
#grades_summary thead th:nth-child(5) { width: 11%; } /* Score */
#grades_summary thead th:nth-child(6) { width: 3%; } /* asset processors */
#grades_summary thead th:nth-child(7) { width: 15%; } /* Details */
#grades_summary thead th:nth-child(8) { width: 3%; } /* progress */
#grades_summary td.due,
#grades_summary td.assignment_score,
#grades_summary td.details { white-space: normal; }
#assignments { overflow-x: auto; }

View File

@ -228,9 +228,15 @@
</div><span class="option-name" data-i18n="grade_analytics">Grade Analytics</span>
<span class="cr-info-bubble" tabindex="0" role="button" aria-label="Grade Analytics info">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span class="cr-info-bubble-text" data-i18n="grade_analytics_desc">Adds an Analytics toggle to the left side of course grade pages with a score-distribution chart and your overall grade over time.</span>
<span class="cr-info-bubble-text" data-i18n="grade_analytics_desc">Adds tools to help you analyze your grades.</span>
</span>
</div>
<div class="sub-options">
<div class="sub-option" style="margin-top:5px">
<input type="checkbox" id="grade_analytics_zones" name="grade_analytics_zones">
<label for="grade_analytics_zones" class="sub-text" data-i18n="grade_analytics_zones">Colored grade zones</label>
</div>
</div>
</div>
<div class="social-buttons">
<a class="discord-button" href="https://discord.gg/GjFh4JA3wh" target="_blank" rel="noopener noreferrer" title="Join our Discord">
@ -598,14 +604,6 @@
<input type="checkbox" id="export-toggles" />
<span>On/off toggles</span>
</div>
<div class="sub-option">
<input type="checkbox" id="export-layout" />
<span>Layout</span>
</div>
<div class="sub-option">
<input type="checkbox" id="export-sidebar" />
<span>Sidebar</span>
</div>
<div class="sub-option">
<input type="checkbox" id="export-todo" />
<span>Todo list</span>

View File

@ -118,6 +118,7 @@ chrome.runtime.onInstalled.addListener(function () {
"sidebar_blur": 0,
"global_search": false,
"grade_analytics": true,
"grade_analytics_zones": false,
}
};

View File

@ -956,6 +956,10 @@ function applyOptionsChanges(changes) {
case "grade_analytics":
watchGradeAnalytics();
break;
case "grade_analytics_zones":
// Colored 10% zones on the line chart — just redraw the charts.
if (gradeAnalyticsActive() && gaOpen && gaData) renderGradeAnalytics();
break;
case "quiz_safe_mode":
// Toggling safe mode changes which features run on quiz pages; reload
// so the gating is applied cleanly.
@ -1089,6 +1093,18 @@ async function applyCustomBackground() {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%);
border-radius: 5px;
}
/* Native left nav column: #left-side > #sticky-container.ic-sticky-frame
(the course/account/group menu links). Tint the whole #left-side column
rather than the inner .ic-sticky-frame, whose height only wraps its
links — the column spans the full viewport height like the other
sidebars, at the same bg_opacity/bg_blur as the Better Todo List
panel. Without this, a custom background (most visible in light mode)
shows through untinted behind the nav links. */
#left-side {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
}
/* Recent feedback lives in #right-side. The dark-mode CSS
(darkmodecss.js) recolors its text, but those rules are dark-mode-
only — so in light mode + custom background the sub-text (context,
@ -1305,6 +1321,20 @@ async function applyCustomBackground() {
-webkit-backdrop-filter: blur(${cardBlur}px) saturate(120%) !important;`
: `background: var(--bcbackground-0) !important;`}
}
/* Card header strip (the course-nickname bar under the hero). Canvas
paints it a solid light color ($ic-color-light) and nothing overrides
that in light mode, so with card transparency on it reads as a solid
band across an otherwise translucent card. Mirror the card surface:
transparent cards drop the strip's background so the card's glass
(tint + blur already applied to .ic-DashboardCard) shows through;
opaque cards paint it the same solid theme color as the card body.
Dark mode already flattens this strip via darkmodecss.js, so this
is inert there (same value). */
.ic-DashboardCard__header_content {
${cardTransparency
? `background: none !important;`
: `background: var(--bcbackground-0) !important;`}
}
tr.student_assignment.assignment_graded.editable > * {
border:none!important
}`;
@ -1727,6 +1757,17 @@ const BETTER_TODO_TIMEFRAME_DAYS = {
let betterTodoProgressFilter = null;
let domContainers = {};
// Better Todo timeframe filter, shared by the task list and the progress
// display so their counts always agree: keeps items due on/before now+range
// (overdue items are before now, so they are kept too). "all" keeps
// everything.
function applyTodoTimeframe(items) {
betterTodoTimeframe = (options.todo_timeframe && Object.prototype.hasOwnProperty.call(BETTER_TODO_TIMEFRAME_DAYS, options.todo_timeframe)) ? options.todo_timeframe : "all";
if (betterTodoTimeframe === "all") return items;
const cutoff = Date.now() + (BETTER_TODO_TIMEFRAME_DAYS[betterTodoTimeframe] * 24 * 60 * 60 * 1000);
return items.filter(item => new Date(item.plannable_date).getTime() <= cutoff);
}
// true when `courseId` is the dimmed-out class because another class is selected.
function progressFilterDim(courseId) {
return betterTodoProgressFilter != null && String(courseId) !== String(betterTodoProgressFilter);
@ -2233,7 +2274,9 @@ function renderProgressRings(container, scopedData) {
const mode = getProgressRingMode();
if (mode === "none") { container.innerHTML = ""; return; }
const allAssignments = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note"));
// Apply the same timeframe filter the list uses so the counts in the
// display match what's shown below it.
const allAssignments = applyTodoTimeframe(scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note")));
const groups = {};
allAssignments.forEach(item => {
@ -2901,13 +2944,9 @@ async function createTodoSections(location) {
completed = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete));
// The timeframe is a persisted Better Todo List sub-option set in the
// popup. Read the current value each render so popup changes apply on
// the next render. Keeps items due on/before now+range (overdue items
// are before now, so they are kept too). Only the Tasks tab is affected.
betterTodoTimeframe = (options.todo_timeframe && Object.prototype.hasOwnProperty.call(BETTER_TODO_TIMEFRAME_DAYS, options.todo_timeframe)) ? options.todo_timeframe : "all";
if (betterTodoTimeframe !== "all") {
const cutoff = Date.now() + (BETTER_TODO_TIMEFRAME_DAYS[betterTodoTimeframe] * 24 * 60 * 60 * 1000);
assignmentsDue = assignmentsDue.filter(item => new Date(item.plannable_date).getTime() <= cutoff);
}
// the next render. Only the Tasks tab is affected (announcements and
// the completed tab always show everything).
assignmentsDue = applyTodoTimeframe(assignmentsDue);
// console.log("assignments", assignmentsDue);
// console.log("announcements", announcements);
// console.log("completed", completed);
@ -3740,8 +3779,15 @@ async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
contentMain?.style.setProperty("backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
contentMain?.style.setProperty("-webkit-backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
}
const sidebarParent = layoutMode === "course" && leftSide ? leftSide : mainWrapper;
if (layoutMode === "course" && leftSide) {
// The rail must always render leftmost. Course-layout pages already
// prepend it into #left-side; dash-layout pages that still have a
// native left nav (accounts, groups, etc.) must too — otherwise the
// native #left-side column (made position:static above) flows before
// #not_right_side and shows up to the LEFT of the Better Sidebar,
// looking like a competing sidebar once the custom background tints
// it. Prepending keeps the order: [Better Sidebar rail][native nav].
const sidebarParent = leftSide ? leftSide : mainWrapper;
if (leftSide) {
leftSide.style.display = "flex";
leftSide.style.flexDirection = "row";
leftSide.style.alignItems = "stretch";
@ -3749,9 +3795,6 @@ async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
leftSide.style.gap = "0";
}
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.setProperty("display", "none");
if (layoutMode !== "course") {
document.getElementById("left-side")?.style.removeProperty("display");
}
if (layoutMode == "dash") {
document.getElementById("header")?.style.setProperty("display", "none");
}
@ -6333,6 +6376,22 @@ const GA_BUCKETS = [
{ 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.
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 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;
const c1 = rgb(stops[k].color), c2 = rgb(stops[k + 1].color);
return `rgb(${lerp(c1[0], c2[0], t)},${lerp(c1[1], c2[1], t)},${lerp(c1[2], c2[2], t)})`;
});
})();
const GA_OPEN_KEY = "grade_analytics_open";
async function getGradeAnalyticsOpenState() {
@ -6366,37 +6425,29 @@ function gradeAnalyticsActive() {
return options.grade_analytics === true && isGradesPage() && !quizSafeModeActive();
}
// Paginated Canvas API GET using the user's session. Unwraps Firefox Xray
// proxies like getData() does. The first page is fetched serially to learn the
// total page count from its Link header; every remaining page is then fetched
// concurrently, so courses with many assignments don't pay one round trip per
// page.
async function gaFetchAll(path) {
const fetchPage = async (url) => {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error("Canvas API " + res.status + " for " + url.pathname);
return { data: JSON.parse(JSON.stringify(await res.json())), link: res.headers.get("Link") || "" };
};
const firstUrl = new URL(path, domain);
firstUrl.searchParams.set("per_page", "100");
const { data: first, link } = await fetchPage(firstUrl);
const out = Array.isArray(first) ? first.slice() : [];
const lastMatch = link.match(/<([^>]+)>;\s*rel="last"/);
if (lastMatch) {
const lastPage = parseInt(new URL(lastMatch[1]).searchParams.get("page") || "1", 10);
if (lastPage > 1) {
const pageUrls = [];
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);
// Grades data is read straight from the #grades_summary table the page
// already rendered — no API round trips, so even courses with hundreds of
// assignments populate instantly, and the numbers always match what the user
// sees (grading periods, unposted grades, etc.). Waits briefly for the table
// to appear on SPA navigations.
function gaWaitForGradesTable(timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const ready = () => {
const table = document.querySelector("#grades_summary");
return table && table.querySelector("tr.student_assignment") ? table : null;
};
const found = ready();
if (found) { resolve(found); return; }
const started = Date.now();
const timer = setInterval(() => {
const table = ready();
if (table) { clearInterval(timer); resolve(table); }
else if (Date.now() - started > timeoutMs) {
clearInterval(timer);
reject(new Error("grades table not found on this page"));
}
const rest = await Promise.all(pageUrls.map(u => fetchPage(u).then(r => r.data)));
for (const page of rest) if (Array.isArray(page)) out.push(...page);
}
}
return out;
}, 250);
});
}
// Entry point: called at init, on SPA navigation, and when the option changes.
@ -6561,18 +6612,18 @@ async function loadGradeAnalytics() {
if (courseId == null) return;
gaLoading = true;
const status = document.getElementById("canvasrefined-ga-status");
if (status) status.textContent = "Loading grade data…";
if (status) status.textContent = "Reading grade data…";
try {
const [assignments, groups] = await Promise.all([
gaFetchAll(`/api/v1/courses/${courseId}/assignments?include[]=submission&order_by=due_at`),
gaFetchAll(`/api/v1/courses/${courseId}/assignment_groups`),
]);
// Parse the grades table the page already rendered instead of hitting
// the paginated API — instant even for courses with hundreds of
// assignments, and always in sync with what the page shows.
const table = await gaWaitForGradesTable();
gaCourseId = courseId;
gaData = computeGradeAnalytics(assignments, groups);
gaData = computeGradeAnalyticsFromPage(table);
gaLoading = false;
// renderGradeAnalytics self-guards on panel existence and canvas
// size; if the panel isn't ready yet, the retry below in
// ensureGradeAnalyticsPanel draws once it is.
// size; if the panel isn't ready yet, the retry in
// ensureGradeAnalyticsPanel / syncGradeAnalyticsUI draws once it is.
renderGradeAnalytics();
} catch (err) {
logError(err);
@ -6583,74 +6634,117 @@ async function loadGradeAnalytics() {
}
}
function gaIsGraded(a) {
return a.published !== false && a.points_possible > 0 &&
(a.submission_types || []).indexOf("not_graded") === -1 &&
a.submission && a.submission.score != null;
// Parses one assignment row of #grades_summary into a plain record. The row
// stashes the original posted score in a hidden "original_score" span (the
// "original_points" span holds points EARNED, not possible), while points
// 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) {
// Graded-type assignment with points possible but no score yet.
if (a.published === false || a.points_possible <= 0) return false;
if ((a.submission_types || []).includes("not_graded")) return false;
return !(a.submission && a.submission.score != null);
function gaParseAssignmentRow(tr) {
const q = (sel) => tr.querySelector(sel);
const titleLink = q(".title a");
const possibleText = q(".tooltip .grade + span")?.textContent || "";
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);
let ungraded = 0, graded = 0;
for (const a of assignments) {
if (gaIsUngraded(a)) { ungraded++; continue; }
if (!gaIsGraded(a)) continue;
const pct = (a.submission.score / a.points_possible) * 100;
let ungraded = 0;
const graded = [];
for (const a of rows) {
if (a.points == null || a.points <= 0) continue; // no points possible
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);
counts[idx >= 0 ? idx : GA_BUCKETS.length - 1]++;
}
const groupName = {};
const weight = {};
for (const g of groups) {
groupName[g.id] = g.name;
weight[g.id] = g.group_weight || 0;
}
const totalWeight = Object.values(weight).reduce((s, w) => s + w, 0);
// 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));
// Running overall grade, in the page's row order, using Canvas's own
// weighting algorithm (GradeCalculator): sum each group's pct × weight
// over groups that have graded work, then scale up to 100% only when
// those weights total less than 100 (weights over 100 are used raw and
// can push the grade past 100). Verified to reproduce the Total shown
// on the page. Point-based courses (no group weights) use points
// earned / points possible.
const running = {};
const points = timeline.map(a => {
const r = (running[a.assignment_group_id] ||= { score: 0, pts: 0 });
r.score += a.submission.score;
r.pts += a.points_possible;
let weighted = 0, used = 0;
for (const gid of Object.keys(running)) {
const g = running[gid];
if (g.pts <= 0) continue;
if (totalWeight > 0) {
weighted += (g.score / g.pts) * (weight[gid] || 0);
used += (weight[gid] || 0);
} else {
weighted += g.score;
used += g.pts;
const pointsGrade = () => {
let s = 0, p = 0;
for (const g of Object.values(running)) { s += g.score; p += g.pts; }
return p > 0 ? (s / p) * 100 : null;
};
const points = graded.map(a => {
const r = (running[a.gid] ||= { score: 0, pts: 0 });
r.score += a.score;
r.pts += a.points;
let grade = null;
if (totalWeight > 0) {
let weighted = 0, fullWeight = 0;
for (const gid of Object.keys(running)) {
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 {
title: a.name,
score: a.submission.score,
points: a.points_possible,
pct,
title: a.title,
score: a.score,
points: a.points,
pct: (a.score / a.points) * 100,
grade,
group: groupName[a.assignment_group_id] || "",
due: a.due_at ? new Date(a.due_at).toLocaleDateString() : "",
due: a.due,
};
});
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
// assignments (or since the first, if fewer). Positive = climbing.
let trend = null;
@ -6662,9 +6756,9 @@ function computeGradeAnalytics(assignments, groups) {
return {
counts,
ungraded,
graded: pcts.length,
graded: graded.length,
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,
points,
};
@ -6691,8 +6785,8 @@ function renderGradeAnalytics() {
stats.innerHTML =
stat("Overall grade", gaData.current == null ? "-" : gaData.current.toFixed(1) + "%") +
stat("Grade trend (last 5)", trendVal, trendColor) +
stat("Graded assignments", gaData.graded) +
stat("Ungraded / no score", gaData.ungraded);
stat("Graded", gaData.graded) +
stat("Ungraded", gaData.ungraded);
const charts = panel.querySelector("#canvasrefined-ga-charts");
charts.style.display = "flex";
@ -6842,6 +6936,22 @@ function gaDrawLine(canvas, tooltip) {
// can cheaply redraw with a highlight on the active dot.
const draw = (hover) => {
ctx.clearRect(0, 0, w, h);
// Optional colored 5% zones behind the plot ("Colored grade zones"
// popup option), tinted red→green. With Fit Y axis on, bands are
// clipped to the visible range.
if (options.grade_analytics_zones) {
GA_ZONE_COLORS.forEach((color, i) => {
const top = Math.min(yMax, (i + 1) * 5);
const bottom = Math.max(yMin, i * 5);
if (top <= bottom) return;
const y1 = pad.t + (1 - (top - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
const y2 = pad.t + (1 - (bottom - yMin) / (yMax - yMin)) * (h - pad.t - pad.b);
ctx.globalAlpha = 0.3;
ctx.fillStyle = color;
ctx.fillRect(pad.l, y1, w - pad.l - pad.r, y2 - y1);
ctx.globalAlpha = 1;
});
}
// Y grid: 5 evenly spaced lines across the current range.
ctx.font = "11px Lato, sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
@ -6866,14 +6976,62 @@ function gaDrawLine(canvas, tooltip) {
ctx.fillStyle = "#2563eb"; ctx.fill();
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";
[[0], [Math.floor((pts.length - 1) / 2)], [pts.length - 1]].forEach(([i]) => {
if (pts.length > 1 && pts.length > 2 && i !== 0 && i !== pts.length - 1 && Math.abs(X(i) - X(0)) < 40) return;
const label = pts[i].due || "";
if (!label) return;
ctx.strokeStyle = "rgba(128,128,128,0.55)";
ctx.lineWidth = 1;
const tick = (x) => {
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)));
// Month boundaries: the first point of each calendar month. Points are
// already in chronological order, so a walking month counter (wrapping
// across Dec → Jan) maps each point to an absolute month.
const boundaries = [];
let prevM = null, absM = 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;
const last = boundaries[boundaries.length - 1];
if (!last || last.absM !== absM) {
boundaries.push({ i, absM, label: (p.due || "").trim().slice(0, 3) });
}
});
boundaries.forEach(b => tick(X(b.i)));
// Collision-aware labels: greedily keep a month label only if it fits
// after the previous kept one (labels are centered, so compare against
// half-widths plus a 6px gap). The first boundary always gets a label;
// so does the last — if it doesn't fit, earlier labels are dropped to
// make room, so the axis ends on a real month instead of mid-run.
const GAP = 6;
const kept = [];
boundaries.forEach((b, idx) => {
const x = X(b.i);
const w = ctx.measureText(b.label).width;
if (idx === 0 || x - w / 2 > kept[kept.length - 1].right + GAP) {
kept.push({ ...b, x, right: x + w / 2 });
}
});
const lastB = boundaries[boundaries.length - 1];
if (lastB && kept[kept.length - 1].i !== lastB.i) {
const x = X(lastB.i);
const w = ctx.measureText(lastB.label).width;
while (kept.length && kept[kept.length - 1].right + GAP > x - w / 2) kept.pop();
kept.push({ ...lastB, x, right: x + w / 2 });
}
kept.forEach((b, k) => {
// Anchor the edge labels inward so they don't clip.
ctx.textAlign = k === 0 ? "left" : (b.i === pts.length - 1 ? "right" : "center");
ctx.fillStyle = text;
ctx.fillText(label, X(i), h - pad.b + 6);
ctx.fillText(b.label, b.x, h - pad.b + 6);
});
// Hover indicator: dashed vertical guide plus a halo ring around the
// hovered dot so it's obvious which point the tooltip describes.

View File

@ -1,5 +1,6 @@
const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics'];
const syncedSubOptions = [
"grade_analytics_zones",
"todo_hide_feedback",
"todo_full_height",
"todo_confetti",
@ -59,8 +60,9 @@ const exportTodo = ["better_todo", "todo_hide_feedback", "todo_full_height", "to
const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_calc_weighted"];
const exportBackground = ["customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", "customBackgroundNasaDaily", "fitImageToScreen", "card_transparency", "bg_opacity", "sidebar_opacity", "bg_blur", "sidebar_blur", "card_opacity", "card_blur"];
// Master "On/off toggles" = every visual toggle (no GPA, no dark-mode schedule,
// no personal productivity features).
const exportToggles = ["dark_mode", "quiz_safe_mode"].concat(exportCardColorToggles, exportLayout, exportSidebar, exportTodo);
// no personal productivity features). Includes the former separate Layout and
// Sidebar groups, plus Grade Analytics and its colored-zones sub-toggle.
const exportToggles = ["dark_mode", "quiz_safe_mode", "grade_analytics", "grade_analytics_zones"].concat(exportCardColorToggles, exportLayout, exportSidebar, exportTodo);
const fontsDropdownStateKey = "fonts_dropdown_open";
const apiurl = "none";
@ -151,6 +153,7 @@ const defaultOptions = {
"equal_height_cards": false,
"hide_new_canvas": true,
"hide_sequence_footer": false,
"grade_analytics_zones": false,
"quiz_safe_mode": false,
"dark_mode_fix": [],
"assignment_states": {},
@ -570,7 +573,7 @@ function toggleAlternateColorsVisibility(darkModeOn) {
// Hide a toggle's sub-options when it's off; auto_dark only hides its time clocks.
function toggleSubOptionsVisibility(option, isOn) {
const togglesWithSubOptions = ["gpa_calc", "assignments_due", "better_todo", "auto_dark"];
const togglesWithSubOptions = ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics"];
if (!togglesWithSubOptions.includes(option)) return;
const optionEl = document.getElementById(option);
if (!optionEl) return;
@ -953,6 +956,7 @@ function setup() {
"fitImageToScreen",
"card_transparency",
"customCardStyles",
"grade_analytics_zones",
],
tabs: {
"advanced-settings": {
@ -1110,7 +1114,7 @@ function setup() {
});
});
toggleBetterSidebarSubOptions(sync["better_sidebar"] === true);
["gpa_calc", "assignments_due", "better_todo", "auto_dark"].forEach(opt => {
["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true);
});
toggleAlternateColorsVisibility(sync["dark_mode"] === true);
@ -1312,12 +1316,6 @@ function setup() {
case "export-background":
final = { ...final, ...(await getExport(storage, exportBackground)) };
break;
case "export-layout":
final = { ...final, ...(await getExport(storage, exportLayout)) };
break;
case "export-sidebar":
final = { ...final, ...(await getExport(storage, exportSidebar)) };
break;
case "export-todo":
final = { ...final, ...(await getExport(storage, exportTodo)) };
break;