From bb6ae93537d55b7e839d8f21fe30e4d5ed4bbbb3 Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Sat, 24 Oct 2026 18:40:06 -0700 Subject: [PATCH] heatmap widget --- _locales/en/messages.json | 27 +++ css/content.css | 52 ++++- html/popup.html | 26 +++ js/background.js | 3 + js/content.js | 479 +++++++++++++++++++++++++++++++++++++- js/popup.js | 19 +- 6 files changed, 595 insertions(+), 11 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c6798e3..5e189c0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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." } } diff --git a/css/content.css b/css/content.css index d27fd76..869e739 100644 --- a/css/content.css +++ b/css/content.css @@ -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 { diff --git a/html/popup.html b/html/popup.html index 9938498..8d9ca54 100644 --- a/html/popup.html +++ b/html/popup.html @@ -1388,6 +1388,32 @@ +
+
+ + +
+
+
+
Heatmap +
+
+
+ + +
+
+ + + px +
+
+
diff --git a/js/background.js b/js/background.js index a04385b..64dcc79 100644 --- a/js/background.js +++ b/js/background.js @@ -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, diff --git a/js/content.js b/js/content.js index 0a3c5e4..2c940d2 100644 --- a/js/content.js +++ b/js/content.js @@ -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 ` +
+ ${getMsg("heatmap_widget", "Heatmap")} +
+ + +
+
+
+

${getMsg("reading_grade_data", "Reading grade data…")}

+
+
+
+

+
+ +
+`; } + +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 = `${date} — ${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(`
${gaEscHtml(course)}${n}
`); + }); + if (entries.length > CR_HM_TIP_ROWS) rows.push(`
+${entries.length - CR_HM_TIP_ROWS}
`); + } else { + head = `${date} — ${getMsg("ga_heatmap_avg", "avg")} ${cell.avg.toFixed(2)}%`; + cell.gradeItems.slice(0, CR_HM_TIP_ROWS).forEach(g => { + rows.push(`
${gaEscHtml(g.title)}${g.course ? `${gaEscHtml(g.course)}` : ""}${g.pct.toFixed(1)}%
`); + }); + if (cell.gradeItems.length > CR_HM_TIP_ROWS) rows.push(`
+${cell.gradeItems.length - CR_HM_TIP_ROWS}
`); + } + tip.innerHTML = rows.length ? `${head}
${rows.join("")}
` : 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) => ``; + 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 = `${gaEscHtml(getMsg("cr_hm_legend_few", "0 submitted"))}${steps}${gaEscHtml(replaceCountPlaceholder(getMsg("cr_hm_legend_many", "{{COUNT}} submitted"), max))}`; + return; + } + const zones = [0, 5, 10, 15, 19].map(i => swatch(`background-color:${GA_ZONE_COLORS[i]};`)).join(""); + legend.innerHTML = `0%${zones}100%${swatch(`background-color:${CR_HM_EMPTY};`)}${gaEscHtml(getMsg("ga_no_grade_legend", "No grade/assignment"))}`; +} + +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) ---- */ diff --git a/js/popup.js b/js/popup.js index 64bb9ec..53dd8ce 100644 --- a/js/popup.js +++ b/js/popup.js @@ -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);