heatmap widget

This commit is contained in:
Guy Sandler 2026-10-24 18:40:06 -07:00
parent a283b40291
commit bb6ae93537
6 changed files with 595 additions and 11 deletions

View File

@ -2026,5 +2026,32 @@
},
"quick_links_store_error": {
"message": "Couldn't save your links. Try shorter URLs."
},
"heatmap_widget": {
"message": "Heatmap"
},
"widget_short_heatmap": {
"message": "Heatmap"
},
"cr_hm_tab_grades": {
"message": "Avg grade"
},
"cr_hm_tab_subs": {
"message": "Submissions"
},
"cr_hm_legend_few": {
"message": "0 submitted"
},
"cr_hm_legend_many": {
"message": "{{COUNT}} submitted"
},
"cr_hm_tip_subs": {
"message": "{{COUNT}} submitted"
},
"cr_hm_no_grades": {
"message": "No graded assignments with due dates yet."
},
"cr_hm_no_subs": {
"message": "No submissions recorded yet."
}
}

View File

@ -611,7 +611,7 @@
/* 4s in / 6s out; colour shifts green -> blue while exhaling */
.canvasrefined-breathing {background:linear-gradient(160deg, color-mix(in srgb, #6ea8ff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
.cr-breath-stage {display:flex;align-items:center;justify-content:center;padding:20px 14px 6px;}
.cr-breath-circle {position:relative;width:112px;height:112px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 40%, #74e3c4, #2fb595);box-shadow:0 0 18px 2px rgba(65,209,175,.4);}
.cr-breath-circle {position:relative;width:var(--cr-breath-circle,112px);height:var(--cr-breath-circle,112px);border-radius:50%;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 40%, #74e3c4, #2fb595);box-shadow:0 0 18px 2px rgba(65,209,175,.4);}
.cr-breath-circle::before {content:"";position:absolute;inset:0;border-radius:50%;background:radial-gradient(circle at 50% 40%, #93a8ff, #536ee0);opacity:0;box-shadow:0 0 26px 5px rgba(101,124,255,.45);}
.canvasrefined-breathing.is-running .cr-breath-circle {animation: cr-breath-pulse 10s ease-in-out infinite, cr-breath-glow 10s ease-in-out infinite;}
.canvasrefined-breathing.is-running .cr-breath-circle::before {animation: cr-breath-tint 10s ease-in-out infinite;}
@ -638,13 +638,17 @@
.canvasrefined-breathing.is-running .cr-breath-phase-out {animation: cr-breath-out-label 10s ease-in-out infinite;}
@keyframes cr-breath-in-label {0%{opacity:1;}38%{opacity:1;}44%{opacity:0;}100%{opacity:0;}}
@keyframes cr-breath-out-label {0%{opacity:0;}38%{opacity:0;}44%{opacity:1;}94%{opacity:1;}100%{opacity:0;}}
.cr-breath-layout {display:flex;flex-direction:column;flex:1;min-height:0;}
.cr-breath-layout {--cr-breath-gap:26px;--cr-breath-circle:112px;display:flex;flex-direction:column;flex:1;min-height:0;}
.cr-breath-left {display:flex;flex-direction:column;align-items:center;justify-content:center;}
.cr-breath-right {display:flex;flex-direction:column;justify-content:center;}
.canvasrefined-breathing.container-mode .cr-breath-layout {flex-direction:row;align-items:center;gap:26px;}
.canvasrefined-breathing.container-mode .cr-breath-left {flex:0 0 40%;width:40%;max-width:40%;}
.canvasrefined-breathing.container-mode .cr-breath-layout {flex-direction:row;align-items:center;gap:var(--cr-breath-gap);}
/* The circle keeps exactly one layout gap of breathing room on its left; the column
hugs circle + gap, so the gap on its right matches, and the timer shares the same
inset so it stays centered under the circle. */
.canvasrefined-breathing.container-mode .cr-breath-left {flex:0 0 calc(var(--cr-breath-circle) + var(--cr-breath-gap));width:calc(var(--cr-breath-circle) + var(--cr-breath-gap));max-width:none;min-width:0;}
.canvasrefined-breathing.container-mode .cr-breath-right {flex:1;min-width:0;}
.canvasrefined-breathing.container-mode .cr-breath-stage {padding:8px 0;}
.canvasrefined-breathing.container-mode .cr-breath-stage {padding:8px 0 8px var(--cr-breath-gap);}
.canvasrefined-breathing.container-mode .cr-breath-display {align-self:stretch;padding-left:var(--cr-breath-gap);padding-right:0;}
.canvasrefined-breathing.container-mode .cr-timer-presets {padding-top:0;}
.canvasrefined-breathing.container-mode .cr-timer-controls {padding-bottom:0;}
.canvasrefined-quotes {container-type:inline-size;background:linear-gradient(150deg, color-mix(in srgb, #b98cff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
@ -708,6 +712,44 @@
.cr-ql-delete-link {align-self:flex-start;padding:4px 0;border:none;background:transparent;color:#e5484d !important;font:inherit;font-size:11.5px;font-weight:600;cursor:pointer;}
.cr-ql-delete-link:hover {text-decoration:underline;}
/* Heatmap (all classes): grade/submission calendar with in-card tabs */
.canvasrefined-heatmap .cr-widget-head {gap:8px;}
.canvasrefined-heatmap .cr-widget-title {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* Hover tooltips are allowed to spill past the card, so this widget never clips —
and it lifts above neighbouring cards while one is showing. */
.canvasrefined-heatmap {overflow:visible;}
.canvasrefined-heatmap.cr-hm-tip-open {position:relative;z-index:6;}
.cr-hm-tabs {display:flex;gap:2px;margin-left:auto;flex:none;}
.cr-hm-tab {border:1px solid transparent;border-radius:7px;background:transparent;color:var(--bctext-2,#9aa3ab);font:inherit;font-size:11px;font-weight:700;letter-spacing:.03em;line-height:1.3;padding:4px 7px;cursor:pointer;white-space:nowrap;}
.cr-hm-tab:hover {color:var(--bctext-0,#2d3b45);background:color-mix(in srgb, var(--bctext-0,#2d3b45) 9%, transparent);}
.cr-hm-tab.is-active {color:var(--bctext-0,#2d3b45);background:color-mix(in srgb, var(--bclinks,#0374b5) 18%, transparent);border-color:color-mix(in srgb, var(--bclinks,#0374b5) 45%, transparent);}
.cr-hm-tab:focus-visible {outline:2px solid color-mix(in srgb, var(--bclinks,#0374b5) 70%, transparent);outline-offset:2px;}
.cr-hm-body {position:relative;display:flex;flex-direction:column;flex:1;min-height:0;gap:8px;padding:12px 14px 14px;}
.cr-hm-status {margin:0;color:var(--bctext-2,#9aa3ab);font-size:12px;line-height:1.45;}
/* flex:1 with min-height:0 keeps the calendar area independent of its content, which
is what lets the grid scale its cells to the card's height. */
.cr-hm-scroll {display:flex;align-items:center;flex:1 1 auto;min-height:0;overflow-x:auto;overflow-y:auto;max-width:100%;}
@supports (align-items: safe center) { .cr-hm-scroll {align-items:safe center;} }
.cr-hm-scroll:focus-visible {outline:2px solid color-mix(in srgb, var(--bclinks,#0374b5) 70%, transparent);outline-offset:2px;}
.cr-hm-grid {display:inline-flex;flex:none;gap:4px;}
.canvasrefined-heatmap .cr-hm-cell:hover {outline:1px solid var(--bctext-0,#2d3b45);outline-offset:1px;}
.cr-hm-foot {display:flex;flex-direction:column;gap:4px;margin-top:auto;}
.cr-hm-legend {display:flex;align-items:center;flex-wrap:wrap;gap:4px;font-size:10.5px;color:var(--bctext-2,#9aa3ab);}
.cr-hm-sw {width:11px;height:11px;border-radius:3px;display:inline-block;flex:none;}
.cr-hm-note {margin:0;color:var(--bctext-2,#9aa3ab);font-size:11px;line-height:1.4;}
.cr-hm-note:empty {display:none;}
.cr-hm-tip {position:absolute;display:none;pointer-events:none;z-index:60;padding:6px 10px;border:1px solid var(--bcborders,#3c3c3c);border-radius:6px;background:var(--bcbackground-0,#161616);color:var(--bctext-0,#f5f5f5);font-size:11.5px;line-height:1.45;box-shadow:0 4px 14px rgba(0,0,0,.35);
/* max-content: the tip can be parked outside the widget body, so a shrink-to-fit
width would collapse against the containing block and turn the box into a
narrow column. */
width:max-content;max-width:270px;}
.cr-hm-tip-list {margin-top:4px;display:flex;flex-direction:column;gap:2px;}
.cr-hm-tip-row {color:var(--bctext-1,#e2e2e2);}
.cr-hm-tip-course {margin-left:6px;color:var(--bctext-2,#9aa3ab);font-size:10.5px;font-weight:500;}
.cr-hm-tip-count {display:flex;align-items:baseline;gap:10px;}
.cr-hm-tip-count span {flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cr-hm-tip-count b {flex:none;color:var(--bclinks,#0374b5);font-variant-numeric:tabular-nums;}
/* Canvas' SpeedGrader/document preview container: give the preview 80% of its
parent instead of Canvas' default height. */
#doc_preview {

View File

@ -1388,6 +1388,32 @@
<input type="range" min="20" max="64" step="1" id="quickLinksIconSizeSlider" data-i18n-aria-label="quick_links_icon_size" aria-label="Icon size">
</div>
</div>
<div class="option-container">
<div class="option" id="heatmap">
<input type="radio" id="off" name="heatmap">
<input type="radio" id="on" name="heatmap">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="heatmap_widget">Heatmap</span>
</div>
<div class="sub-options">
<div class="sub-option widget-inline-row">
<label for="heatmap_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="heatmap_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
<div class="sub-option widget-inline-row" style="margin-top:6px;">
<label for="heatmap_height" class="sub-text widget-inline-label" data-i18n="height">Height</label>
<input type="number" id="heatmap_height" class="card-input" min="0" max="2000" data-i18n-placeholder="auto" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
</div>
</div>
</div>
</div>

View File

@ -56,6 +56,8 @@ chrome.runtime.onInstalled.addListener(function () {
"daily_quotes_position": "top",
"quick_links": false,
"quick_links_position": "cards_end",
"heatmap": false,
"heatmap_position": "below",
"widget_row_height": 0,
"dashboard_notes_height": 0,
"gpa_calc_height": 0,
@ -63,6 +65,7 @@ chrome.runtime.onInstalled.addListener(function () {
"breathing_height": 0,
"daily_quotes_height": 0,
"quick_links_height": 0,
"heatmap_height": 0,
"quick_links_font_size": 12,
"quick_links_icon_size": 36,
"better_todo": true,

View File

@ -1110,6 +1110,11 @@ function applyOptionsChanges(changes) {
case "quick_links_height":
loadQuickLinksWidget();
break;
case "heatmap":
case "heatmap_position":
case "heatmap_height":
loadHeatmapWidget();
break;
case "quick_links_font_size":
case "quick_links_icon_size":
document.querySelectorAll(".canvasrefined-quick-links").forEach(quickLinksApplySizes);
@ -1137,6 +1142,8 @@ function applyOptionsChanges(changes) {
document.querySelectorAll(".canvasrefined-widget-row").forEach(row => {
applyWidgetRowLayout(row, row.dataset.area);
});
// The row height is the heatmap's height budget — refit its calendar.
crHeatmapRenderAll();
break;
case "dashboard_grades":
case "grade_hover":
@ -7245,9 +7252,9 @@ function placeGPAElements(dashboardContainer, container, container2) {
}
}
const CR_WIDGET_IDS = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links"];
const CR_WIDGET_IDS = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links", "heatmap"];
// Widgets rendered by the generic loader: one root element, no GPA-style pair.
const CR_SIMPLE_WIDGET_IDS = ["focus_timer", "breathing", "daily_quotes", "quick_links"];
const CR_SIMPLE_WIDGET_IDS = ["focus_timer", "breathing", "daily_quotes", "quick_links", "heatmap"];
function isCRWidget(id) { return CR_WIDGET_IDS.indexOf(id) !== -1; }
// Shared full-width row (top/below): slot width is the % from the Widgets tab.
@ -8012,6 +8019,7 @@ const CR_WIDGET_DEFAULT_POSITION = {
breathing: "cards_end",
daily_quotes: "top",
quick_links: "cards_end",
heatmap: "below",
};
function crWidgetPosition(id) {
@ -8474,11 +8482,478 @@ function loadDailyQuotesWidget() {
});
}
/* ---- Heatmap (every class combined: average grade / submissions per day) ---- */
// Same GitHub-style calendar as Grade Analytics' heatmap tab, but pooled across
// all active courses. Data comes from one assignments call per course with the
// submission inlined (due date, points, score and submitted_at in one response).
const CR_HEATMAP_CACHE_KEY = "heatmap_widget_cache_v1";
const CR_HEATMAP_REFRESH_MS = 30 * 60 * 1000; // refetch once the cache is older than this
const CR_HEATMAP_MAX_PAGES = 5; // 500 assignments per course, safety net
const CR_HEATMAP_PARALLEL = 4; // concurrent course requests (Canvas rate limits)
const CR_HM_CELL_MIN = 7; // cell size range: the grid scales to the card height
const CR_HM_CELL_MAX = 30;
const CR_HM_CELL_DEFAULT = 11;
const CR_HM_GAP = 3;
const CR_HM_MONTH_H = 15; // room for the month labels above the grid
const CR_HM_EMPTY = "color-mix(in srgb, var(--bctext-1,#e2e2e2) 20%, transparent)";
// Submission cells are the dark-mode link color at an opacity that grows with the
// day's count, over the same neutral box the grade tab uses for "nothing here".
const CR_HM_SUB_BASE = CR_HM_EMPTY;
const CR_HM_MUTED = "var(--bctext-2,#9aa3ab)";
const CR_HM_TIP_ROWS = 8;
let crHeatmapTab = "grades"; // active tab: "grades" | "subs"
let crHeatmapData = null; // built day buckets for all courses
let crHeatmapLoadedAt = 0; // when the shown data was fetched
let crHeatmapLoading = false;
let crHeatmapStarted = false; // cache read kicked off (once per page view)
function heatmapWidgetHtml() { return `
<div class="cr-widget-head">
<span class="cr-widget-title">${getMsg("heatmap_widget", "Heatmap")}</span>
<div class="cr-hm-tabs" role="tablist">
<button type="button" class="cr-hm-tab" data-cr-hm-tab="grades" role="tab" aria-selected="true">${getMsg("cr_hm_tab_grades", "Avg grade")}</button>
<button type="button" class="cr-hm-tab" data-cr-hm-tab="subs" role="tab" aria-selected="false">${getMsg("cr_hm_tab_subs", "Submissions")}</button>
</div>
</div>
<div class="cr-hm-body">
<p class="cr-hm-status">${getMsg("reading_grade_data", "Reading grade data…")}</p>
<div class="cr-hm-scroll"><div class="cr-hm-grid"></div></div>
<div class="cr-hm-foot">
<div class="cr-hm-legend"></div>
<p class="cr-hm-note"></p>
</div>
<div class="cr-hm-tip" role="tooltip"></div>
</div>
`; }
function wireHeatmapWidget(el) {
el.querySelectorAll("[data-cr-hm-tab]").forEach(btn => {
btn.addEventListener("click", () => {
const tab = btn.dataset.crHmTab;
if (!tab || tab === crHeatmapTab) return;
crHeatmapTab = tab === "subs" ? "subs" : "grades";
crHeatmapRender(el);
});
});
// The grid is sized from the card's height, so a resize means a rebuild.
crHeatmapObserver().observe(el);
updateHeatmapWidget(el);
}
let crHeatmapResizeObserver = null;
function crHeatmapObserver() {
if (!crHeatmapResizeObserver) {
crHeatmapResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) crHeatmapRender(entry.target);
});
}
return crHeatmapResizeObserver;
}
function updateHeatmapWidget(el) {
crHeatmapEnsureData();
crHeatmapRender(el);
}
function loadHeatmapWidget() {
crLoadWidget("heatmap", {
className: "canvasrefined-heatmap",
html: heatmapWidgetHtml(),
wire: wireHeatmapWidget,
update: updateHeatmapWidget,
});
}
function crHeatmapDayStart(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
return d;
}
// Per-course submissions, only used when the assignments include is ignored.
async function crHeatmapFetchSubmissions(course) {
const url = `${domain}/api/v1/courses/${course.id}/students/submissions?student_ids[]=self&per_page=100`;
const list = await gsGetJsonPages(url, { maxPages: CR_HEATMAP_MAX_PAGES });
const map = new Map();
for (const sub of list) {
if (sub && sub.assignment_id != null) map.set(String(sub.assignment_id), sub);
}
return map;
}
async function crHeatmapFetchCourse(course) {
const url = `${domain}/api/v1/courses/${course.id}/assignments?include[]=submission&per_page=100`;
const list = await gsGetJsonPages(url, { maxPages: CR_HEATMAP_MAX_PAGES });
const code = String(course.course_code || course.name || "");
// `include[]=submission` is documented, but a few instances ignore it: when no
// assignment carries a submission key at all, ask the submissions endpoint.
let subs = null;
if (list.length && !list.some(a => a && Object.prototype.hasOwnProperty.call(a, "submission"))) {
subs = await crHeatmapFetchSubmissions(course).catch(() => null);
}
const out = [];
for (const a of list) {
if (!a || a.name == null) continue;
const sub = a.submission || (subs && subs.get(String(a.id))) || null;
out.push({
course: code,
title: String(a.name),
due: a.due_at || null,
points: typeof a.points_possible === "number" && a.points_possible > 0 ? a.points_possible : 0,
score: sub && typeof sub.score === "number" ? sub.score : null,
excused: !!(sub && sub.excused === true),
submitted: sub && sub.submitted_at ? sub.submitted_at : null,
});
}
return out;
}
async function crHeatmapFetchAll() {
if (crHeatmapLoading) return;
crHeatmapLoading = true;
crHeatmapRenderAll();
try {
const courses = await gsFetchCourseList();
const items = [];
let next = 0;
const worker = async () => {
while (next < courses.length) {
const course = courses[next++];
try {
const list = await crHeatmapFetchCourse(course);
for (const item of list) items.push(item);
} catch (e) {
// One unreachable course must not blank the whole widget.
}
}
};
const workers = [];
for (let i = 0; i < Math.min(CR_HEATMAP_PARALLEL, courses.length); i++) workers.push(worker());
await Promise.all(workers);
// Keep the previous data rather than drawing an empty grid on a dead session.
if (items.length) {
crHeatmapData = crHeatmapBuild(items);
crHeatmapLoadedAt = Date.now();
try {
chrome.storage.local.set({ [CR_HEATMAP_CACHE_KEY]: { at: crHeatmapLoadedAt, items } });
} catch (e) { /* cache write failure is non-fatal */ }
}
} catch (e) {
logError(e);
}
crHeatmapLoading = false;
crHeatmapRenderAll();
}
// Cache first (instant paint), then refresh in the background when stale.
function crHeatmapEnsureData() {
if (crHeatmapStarted) {
if (!crHeatmapLoading && Date.now() - crHeatmapLoadedAt > CR_HEATMAP_REFRESH_MS) crHeatmapFetchAll();
return;
}
crHeatmapStarted = true;
chrome.storage.local.get(CR_HEATMAP_CACHE_KEY, result => {
const cache = result && result[CR_HEATMAP_CACHE_KEY];
if (cache && Array.isArray(cache.items) && cache.items.length) {
crHeatmapData = crHeatmapBuild(cache.items);
crHeatmapLoadedAt = Number(cache.at) || 0;
}
crHeatmapRenderAll();
if (Date.now() - crHeatmapLoadedAt > CR_HEATMAP_REFRESH_MS) crHeatmapFetchAll();
});
}
// Buckets every course's work by day: grades by due date (like Grade Analytics),
// submissions by the day they were turned in.
function crHeatmapBuild(items) {
const byDay = new Map();
const range = { grades: { min: null, max: null }, subs: { min: null, max: null } };
let maxCount = 0, subTotal = 0, undated = 0;
const cellFor = (d, kind) => {
const key = d.getTime();
let cell = byDay.get(key);
if (!cell) {
cell = { date: d, scores: [], gradeItems: [], subs: [] };
byDay.set(key, cell);
}
const r = range[kind];
if (!r.min || key < r.min.getTime()) r.min = d;
if (!r.max || key > r.max.getTime()) r.max = d;
return cell;
};
for (const item of items) {
if (item.score != null && !item.excused && item.points > 0) {
const d = crHeatmapDayStart(item.due);
if (d) {
const pct = (item.score / item.points) * 100;
const cell = cellFor(d, "grades");
cell.scores.push(pct);
cell.gradeItems.push({ title: item.title, course: item.course, pct });
} else {
undated++;
}
}
if (item.submitted) {
const d = crHeatmapDayStart(item.submitted);
if (d) {
cellFor(d, "subs").subs.push({ title: item.title, course: item.course });
subTotal++;
}
}
}
if (!byDay.size) return null;
for (const cell of byDay.values()) {
cell.avg = cell.scores.length ? cell.scores.reduce((a, b) => a + b, 0) / cell.scores.length : null;
cell.count = cell.subs.length;
if (cell.count > maxCount) maxCount = cell.count;
}
return { byDay, range, maxCount, subTotal, undated };
}
function crHeatmapTabRange(data) {
const r = data.range[crHeatmapTab === "subs" ? "subs" : "grades"];
return r && r.min && r.max ? r : null;
}
function crHeatmapRenderAll() {
document.querySelectorAll(".canvasrefined-heatmap").forEach(el => crHeatmapRender(el));
}
// Grades reuse the Grade Analytics zone palette; submissions paint the dark-mode
// link color, more opaque the more was turned in that day.
function crHeatmapCellStyle(cell, data) {
if (crHeatmapTab === "subs") {
const max = Math.max(1, data.maxCount);
const ratio = cell ? cell.count / max : 0;
const pct = Math.round(Math.max(0, Math.min(1, ratio)) * 1000) / 10;
const tint = `color-mix(in srgb, var(--bclinks,#56caf0) ${pct}%, transparent)`;
return `background-color:${CR_HM_SUB_BASE};background-image:linear-gradient(${tint},${tint});`;
}
return `background-color:${cell && cell.avg != null ? gaHeatmapColor(cell.avg) : CR_HM_EMPTY};`;
}
// Cell size that makes the seven weekday rows use the card's free height. Cells
// grow into surplus space, and only shrink when the card's height is actually fixed
// (a set widget/row height) — in a content-sized card the free height follows the
// grid, so shrinking would ratchet the map smaller on every pass.
function crHeatmapDefiniteHeight(el) {
if (el.style && el.style.height) return true;
const row = el.closest ? el.closest(".canvasrefined-widget-row") : null;
return !!(row && row.style.height);
}
function crHeatmapMetrics(el) {
const gap = CR_HM_GAP, monthH = CR_HM_MONTH_H;
const grid = el.querySelector(".cr-hm-grid");
const scroll = el.querySelector(".cr-hm-scroll");
const current = parseInt(grid && grid.dataset.crHmCell, 10) || CR_HM_CELL_DEFAULT;
// The scroll box is exactly the grid's available box: its clientHeight already
// drops padding and any horizontal scrollbar, so one measurement covers it all.
const avail = scroll ? scroll.clientHeight : 0;
const need = (c) => c * 7 + gap * 6 + monthH;
const slack = 2; // keep a hair of breathing room so rounding never clips a row
const fit = Math.floor((avail - slack - monthH - gap * 6) / 7);
const clamped = Number.isFinite(fit) ? Math.min(CR_HM_CELL_MAX, Math.max(CR_HM_CELL_MIN, fit)) : CR_HM_CELL_DEFAULT;
let cell = current;
// 0 = not laid out yet (or hidden); keep the default until there is a real box.
if (avail > 0) {
if (need(current) < avail - slack && clamped > current) cell = clamped;
else if (crHeatmapDefiniteHeight(el) && need(current) > avail - slack && clamped < current) cell = clamped;
}
return { cell, gap, monthH };
}
function crHeatmapShowTip(tip, e, cell) {
const date = cell.date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const rows = [];
let head = "";
if (crHeatmapTab === "subs") {
head = `<b>${date}</b> — ${replaceCountPlaceholder(getMsg("cr_hm_tip_subs", "{{COUNT}} submitted"), cell.count)}`;
// One row per class, most submissions first.
const byCourse = new Map();
for (const s of cell.subs) {
const key = s.course || "—";
byCourse.set(key, (byCourse.get(key) || 0) + 1);
}
const entries = [...byCourse.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
entries.slice(0, CR_HM_TIP_ROWS).forEach(([course, n]) => {
rows.push(`<div class="cr-hm-tip-row cr-hm-tip-count"><span>${gaEscHtml(course)}</span><b>${n}</b></div>`);
});
if (entries.length > CR_HM_TIP_ROWS) rows.push(`<div class="cr-hm-tip-row">+${entries.length - CR_HM_TIP_ROWS}</div>`);
} else {
head = `<b>${date}</b> — ${getMsg("ga_heatmap_avg", "avg")} <b style="color:${gaHeatmapColor(cell.avg)};">${cell.avg.toFixed(2)}%</b>`;
cell.gradeItems.slice(0, CR_HM_TIP_ROWS).forEach(g => {
rows.push(`<div class="cr-hm-tip-row cr-hm-tip-count"><span>${gaEscHtml(g.title)}${g.course ? `<span class="cr-hm-tip-course">${gaEscHtml(g.course)}</span>` : ""}</span><b style="color:${gaHeatmapColor(g.pct)};">${g.pct.toFixed(1)}%</b></div>`);
});
if (cell.gradeItems.length > CR_HM_TIP_ROWS) rows.push(`<div class="cr-hm-tip-row">+${cell.gradeItems.length - CR_HM_TIP_ROWS}</div>`);
}
tip.innerHTML = rows.length ? `${head}<div class="cr-hm-tip-list">${rows.join("")}</div>` : head;
tip.style.display = "block";
const card = tip.closest && tip.closest(".canvasrefined-heatmap");
if (card) card.classList.add("cr-hm-tip-open");
// Absolute inside the (non-clipping) widget body, but free to spill past the card:
// only the viewport bounds it, so a cell at the card's edge still shows its tooltip.
const host = tip.offsetParent || tip.parentNode;
const hostRect = host.getBoundingClientRect();
const box = e.currentTarget.getBoundingClientRect();
const tw = tip.offsetWidth, th = tip.offsetHeight;
const pad = 8;
const vw = window.innerWidth, vh = window.innerHeight;
let left = box.right + pad;
if (left + tw > vw - pad) left = box.left - tw - pad;
left = Math.max(pad, Math.min(left, Math.max(pad, vw - tw - pad)));
let top = box.top + (box.height - th) / 2;
top = Math.max(pad, Math.min(top, Math.max(pad, vh - th - pad)));
tip.style.left = (left - hostRect.left) + "px";
tip.style.top = (top - hostRect.top) + "px";
}
function crHeatmapHideTip(tip) {
if (!tip) return;
tip.style.display = "none";
const card = tip.closest && tip.closest(".canvasrefined-heatmap");
if (card) card.classList.remove("cr-hm-tip-open");
}
function crHeatmapBuildGrid(grid, data, tip, metrics) {
const { cell: cellSize, gap, monthH } = metrics;
const range = crHeatmapTabRange(data);
const start = new Date(range.min);
start.setDate(start.getDate() - start.getDay());
const end = new Date(range.max);
end.setDate(end.getDate() + (6 - end.getDay()));
grid.dataset.crHmCell = String(cellSize);
grid.textContent = "";
const labels = document.createElement("div");
labels.style.cssText = `display:flex;flex-direction:column;gap:${gap}px;padding-top:${monthH}px;padding-right:2px;`;
["", "Mon", "", "Wed", "", "Fri", ""].forEach(text => {
const label = document.createElement("div");
label.style.cssText = `height:${cellSize}px;font-size:9px;line-height:${cellSize}px;color:${CR_HM_MUTED};white-space:nowrap;`;
label.textContent = text;
labels.appendChild(label);
});
grid.appendChild(labels);
const wrap = document.createElement("div");
wrap.style.cssText = `position:relative;padding-top:${monthH}px;`;
const cols = document.createElement("div");
cols.style.cssText = `display:flex;gap:${gap}px;`;
wrap.appendChild(cols);
grid.appendChild(wrap);
const cursor = new Date(start);
let week = 0, prevMonth = null;
while (cursor.getTime() <= end.getTime()) {
const col = document.createElement("div");
col.style.cssText = `display:flex;flex-direction:column;gap:${gap}px;`;
// Month label once the week's Thursday enters a new month.
const thursday = new Date(cursor);
thursday.setDate(thursday.getDate() + 4);
if (prevMonth == null || thursday.getMonth() !== prevMonth) {
prevMonth = thursday.getMonth();
const monthLabel = document.createElement("div");
monthLabel.textContent = new Date(thursday.getFullYear(), prevMonth, 1).toLocaleDateString(undefined, { month: "short" });
monthLabel.style.cssText = `position:absolute;top:0;left:${week * (cellSize + gap)}px;font-size:10px;line-height:1;color:${CR_HM_MUTED};white-space:nowrap;`;
wrap.appendChild(monthLabel);
}
for (let day = 0; day < 7; day++) {
const cell = data.byDay.get(cursor.getTime());
const div = document.createElement("div");
div.className = "cr-hm-cell";
div.style.cssText = `width:${cellSize}px;height:${cellSize}px;flex:none;border-radius:${Math.max(2, Math.round(cellSize / 4))}px;${crHeatmapCellStyle(cell, data)}`;
if (tip && cell && (crHeatmapTab === "subs" ? cell.count > 0 : cell.avg != null)) {
div.addEventListener("mousemove", (e) => crHeatmapShowTip(tip, e, cell));
div.addEventListener("mouseleave", () => crHeatmapHideTip(tip));
}
col.appendChild(div);
cursor.setDate(cursor.getDate() + 1);
}
cols.appendChild(col);
week++;
}
}
function crHeatmapBuildLegend(legend, data, metrics) {
const sw = Math.max(8, Math.min(13, Math.round(metrics.cell)));
const swatch = (style) => `<span class="cr-hm-sw" style="width:${sw}px;height:${sw}px;${style}"></span>`;
if (crHeatmapTab === "subs") {
const max = Math.max(1, data.maxCount);
const steps = [0, 0.25, 0.5, 0.75, 1].map(t => {
const pct = Math.round(t * 1000) / 10;
const tint = `color-mix(in srgb, var(--bclinks,#56caf0) ${pct}%, transparent)`;
return swatch(`background-color:${CR_HM_SUB_BASE};background-image:linear-gradient(${tint},${tint});`);
}).join("");
legend.innerHTML = `<span>${gaEscHtml(getMsg("cr_hm_legend_few", "0 submitted"))}</span>${steps}<span>${gaEscHtml(replaceCountPlaceholder(getMsg("cr_hm_legend_many", "{{COUNT}} submitted"), max))}</span>`;
return;
}
const zones = [0, 5, 10, 15, 19].map(i => swatch(`background-color:${GA_ZONE_COLORS[i]};`)).join("");
legend.innerHTML = `<span>0%</span>${zones}<span>100%</span>${swatch(`background-color:${CR_HM_EMPTY};`)}<span>${gaEscHtml(getMsg("ga_no_grade_legend", "No grade/assignment"))}</span>`;
}
function crHeatmapRender(el) {
if (!el) return;
el.querySelectorAll("[data-cr-hm-tab]").forEach(btn => {
const active = btn.dataset.crHmTab === crHeatmapTab;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
});
const status = el.querySelector(".cr-hm-status");
const grid = el.querySelector(".cr-hm-grid");
const legend = el.querySelector(".cr-hm-legend");
const note = el.querySelector(".cr-hm-note");
const tip = el.querySelector(".cr-hm-tip");
if (!grid) return;
const data = crHeatmapData;
const range = data ? crHeatmapTabRange(data) : null;
if (!range) {
if (status) {
status.style.display = "";
const emptyKey = crHeatmapTab === "subs" ? "cr_hm_no_subs" : "cr_hm_no_grades";
const emptyFallback = crHeatmapTab === "subs" ? "No submissions recorded yet." : "No graded assignments with due dates yet.";
status.textContent = !data && crHeatmapLoading
? getMsg("reading_grade_data", "Reading grade data…")
: getMsg(emptyKey, emptyFallback);
}
grid.textContent = "";
grid.dataset.crHmToken = "";
crHeatmapHideTip(tip);
if (legend) legend.textContent = "";
if (note) note.textContent = "";
return;
}
if (status) status.style.display = "none";
const metrics = crHeatmapMetrics(el);
const token = `${crHeatmapTab}:${metrics.cell}:${range.min.getTime()}:${range.max.getTime()}:${data.maxCount}:${data.subTotal}:${data.byDay.size}`;
if (grid.dataset.crHmToken !== token) {
grid.dataset.crHmToken = token;
crHeatmapHideTip(tip);
crHeatmapBuildGrid(grid, data, tip, metrics);
if (legend) crHeatmapBuildLegend(legend, data, metrics);
// The rebuilt legend can re-wrap and change the free height, so settle the
// cell size in the same tick (sync, so it also works in a hidden tab).
const passes = (parseInt(grid.dataset.crHmSettle, 10) || 0) + 1;
if (passes <= 3) {
grid.dataset.crHmSettle = String(passes);
crHeatmapRender(el);
return;
}
}
grid.dataset.crHmSettle = "0";
if (note) {
note.textContent = crHeatmapTab === "subs"
? ""
: (data.undated > 0 ? replaceCountPlaceholder(getMsg("ga_heatmap_undated", "{{COUNT}} graded assignment(s) without a due date not shown."), data.undated) : "");
}
}
function loadAllSimpleWidgets() {
loadFocusTimer();
loadBreathingWidget();
loadDailyQuotesWidget();
loadQuickLinksWidget();
loadHeatmapWidget();
}
/* ---- Quick Links (editable links with theme-aware preset icons) ---- */

View File

@ -1,4 +1,4 @@
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', 'focus_timer', 'breathing', 'daily_quotes', 'quick_links', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details'];
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', 'focus_timer', 'breathing', 'daily_quotes', 'quick_links', 'heatmap', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details'];
const syncedSubOptions = [
"grade_analytics_zones",
"hide_course_images",
@ -30,6 +30,7 @@ const syncedSubOptions = [
"breathing_preset_3",
"daily_quotes_position",
"quick_links_position",
"heatmap_position",
"widget_row_height",
"dashboard_notes_height",
"gpa_calc_height",
@ -37,6 +38,7 @@ const syncedSubOptions = [
"breathing_height",
"daily_quotes_height",
"quick_links_height",
"heatmap_height",
"quick_links_font_size",
"quick_links_icon_size",
"auto_dark",
@ -274,6 +276,8 @@ const defaultOptions = {
"daily_quotes_position": "top",
"quick_links": false,
"quick_links_position": "cards_end",
"heatmap": false,
"heatmap_position": "below",
// Height in px (0 = auto); widget_row_height is the shared Top/Bottom row.
"widget_row_height": 0,
"dashboard_notes_height": 0,
@ -282,6 +286,7 @@ const defaultOptions = {
"breathing_height": 0,
"daily_quotes_height": 0,
"quick_links_height": 0,
"heatmap_height": 0,
// Quick Links tile label font size in px.
"quick_links_font_size": 12,
// Quick Links tile icon box size in px.
@ -1100,6 +1105,7 @@ const WIDGET_LIST = [
{ id: "breathing", nameKey: "breathing_breaks", shortKey: "widget_short_breathing" },
{ id: "daily_quotes", nameKey: "daily_quotes", shortKey: "widget_short_quotes" },
{ id: "quick_links", nameKey: "quick_links", shortKey: "widget_short_links" },
{ id: "heatmap", nameKey: "heatmap_widget", shortKey: "widget_short_heatmap" },
];
function widgetPosFromSync(id, sync) {
@ -1314,6 +1320,7 @@ function setupWidgetPosition() {
{ select: "breathing_position", key: "breathing_position" },
{ select: "daily_quotes_position", key: "daily_quotes_position" },
{ select: "quick_links_position", key: "quick_links_position" },
{ select: "heatmap_position", key: "heatmap_position" },
].forEach(({ select, key }) => {
const el = document.getElementById(select);
if (!el) return;
@ -1396,7 +1403,7 @@ function setupWidgetTimeInput(prefix, initial) {
}
function toggleSubOptionsVisibility(option, isOn) {
const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links", "heatmap", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
if (!togglesWithSubOptions.includes(option)) return;
const optionEl = document.getElementById(option);
if (!optionEl) return;
@ -1999,6 +2006,10 @@ async function setup() {
identifier: "quick_links_height",
setup: (initial) => setupWidgetHeightInput("quick_links_height", initial),
},
{
identifier: "heatmap_height",
setup: (initial) => setupWidgetHeightInput("heatmap_height", initial),
},
{
identifier: "quick_links_font_size",
setup: (initial) => setupRangeSlider("quick_links_font_size", "quickLinksFontSizeSlider", "quickLinksFontSizeValue", null, 12, 24, "px", initial, 8),
@ -2073,7 +2084,7 @@ async function setup() {
chrome.storage.sync.set({ "hide_course_images": false });
}
toggleSubOptionsVisibility(option, status);
if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "breathing" || option === "daily_quotes" || option === "quick_links") {
if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "breathing" || option === "daily_quotes" || option === "quick_links" || option === "heatmap") {
if (refreshWidgetOrder) refreshWidgetOrder();
}
});
@ -2083,7 +2094,7 @@ async function setup() {
chrome.storage.sync.get("todo_celebrations_enabled", v => {
toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false);
});
["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "quick_links", "heatmap", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true);
});
toggleAlternateColorsVisibility(sync["dark_mode"] === true);