diff --git a/html/popup.html b/html/popup.html
index 5a46ab9..490e926 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -1183,6 +1183,22 @@
This will reset all settings to the default. You will need to refresh the page after.
+
diff --git a/js/content.js b/js/content.js
index 2df726e..765eb2e 100644
--- a/js/content.js
+++ b/js/content.js
@@ -1623,6 +1623,19 @@ function recieveMessage(request, sender, sendResponse) {
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
+ case ("clearPlannerCache"):
+ // "Clear planner cache" (Report issue tab in the popup): drop
+ // the cache, re-fetch fresh planner data, and re-render every
+ // consumer in this tab.
+ (async () => {
+ try { await chrome.storage.local.remove(PLANNER_CACHE_KEY); } catch (e) { /* nothing stored */ }
+ if (options.assignments_due === true || options.better_todo === true) {
+ const items = await loadPlannerItems();
+ refreshPlannerConsumers(items);
+ }
+ sendResponse(true);
+ })();
+ return true; // keep the message channel open for async sendResponse
default: sendResponse(true);
}
}
@@ -6991,32 +7004,118 @@ function changeFavicon() {
function getAssignments() {
if (options.assignments_due === true || options.better_todo === true) {
- // Fetch planner items from as far back as possible so overdue tasks
- // always appear, no matter how long ago they were due. The planner
- // API defaults start_date to "now" (which would hide every overdue
- // item), so a far-past start date is required. Canvas returns planner
- // items oldest-first in pages, so every page must be followed — a
- // single request would only return the oldest page and silently drop
- // all recent items.
- assignments = getAllPlannerItems();
+ assignments = loadPlannerItems();
cardAssignments = preloadAssignmentEls();
}
}
-// Far-past start date for the planner items fetch. Concluded courses are
-// excluded by the API by default, so this only pulls history from the user's
-// currently active courses, which keeps the payload bounded.
-const PLANNER_START_DATE = "2000-01-01";
+// ===================== Planner items (cached) =====================
+// The planner list powers the to-do list, dashboard cards, reminders, and
+// progress rings. It used to be re-fetched from 2000-01-01 on every page
+// load — dozens of sequential requests for students with long histories,
+// which was the main cause of the 1.0.0 slow-load reports. Now:
+// - items are cached in chrome.storage.local and served instantly,
+// - each page load only fetches a small recent window (14 days),
+// - a full lookback walk (max 1 year) runs at most once a week, and only
+// while the tab is idle, and
+// - items are limited to courses the student is currently enrolled in.
+// Canvas returns planner items oldest-first, so any fetch that starts far
+// in the past must page through everything newer; capping the lookback at
+// one year bounds both the request count and the cache size. Overdue items
+// older than a year are intentionally dropped. (Concluded courses are
+// excluded by the planner API by default, so this is history from the
+// user's active courses only.)
+const PLANNER_CACHE_KEY = "planner_cache_v1";
+const PLANNER_LOOKBACK_DAYS = 365; // max history fetched from Canvas
+const PLANNER_WINDOW_DAYS = 14; // per-load "what changed" refresh window
+const PLANNER_FULL_REFRESH_DAYS = 7; // min time between full lookback walks
// Hard cap on pages fetched (50 pages * 100 items = 5000 items) as a safety
// net against a malformed/misbehaving next link.
const PLANNER_MAX_PAGES = 50;
-// Fetches every page of /api/v1/planner/items since PLANNER_START_DATE.
-// Uses the same session/headers as getData but follows the Link "next"
-// headers until exhausted.
-async function getAllPlannerItems() {
+function plannerDateDaysAgo(days) {
+ return new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
+}
+
+// Stable identity for a planner item (same pair the complete-toggle uses).
+function plannerItemKey(item) {
+ return `${item.plannable_type}|${item.plannable_id}`;
+}
+
+// ===================== Active-enrollment filter =====================
+// Reported bug: the to-do list showed assignments/announcements from old,
+// concluded classes. The planner API excludes concluded *courses*, but not
+// courses where only the student's *enrollment* has concluded (common at
+// term boundaries), so those items still leak through. This fetches the ids
+// of the user's currently-active course enrollments so planner items can be
+// filtered to real, current classes. Returns null on failure so callers
+// skip filtering instead of hiding everything.
+async function fetchActiveCourseIds() {
+ const ids = new Set();
+ let url = `${domain}/api/v1/courses?enrollment_state=active&per_page=100`;
+ // 10 pages * 100 courses is far beyond any real enrollment count; just a
+ // safety net against a malformed next link.
+ for (let page = 0; page < 10 && url; page++) {
+ let response;
+ let data;
+ try {
+ response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ }
+ });
+ data = await response.json();
+ } catch (e) {
+ return null;
+ }
+ if (!response.ok || !Array.isArray(data)) return null;
+ for (const course of data) {
+ if (course && course.id != null) ids.add(String(course.id));
+ }
+ url = getNextPageUrl(response.headers.get("Link"));
+ }
+ return ids;
+}
+
+// Keeps only items belonging to courses in `courseIds` (Set or array).
+// null/undefined means "unknown" (the enrollment fetch failed), so no
+// filtering is applied. Personal items with no course context (planner
+// notes, non-course contexts like groups) are always kept.
+function filterPlannerItemsByActiveCourses(items, courseIds) {
+ if (!courseIds) return items;
+ const active = (courseIds instanceof Set) ? courseIds : new Set(courseIds.map(String));
+ return items.filter(item => {
+ const cid = item.course_id ?? (item.context_type === "course" ? item.context_id : null);
+ return cid == null || active.has(String(cid));
+ });
+}
+
+async function readPlannerCache() {
+ try {
+ const result = await chrome.storage.local.get(PLANNER_CACHE_KEY);
+ const cache = result && result[PLANNER_CACHE_KEY];
+ if (cache && Array.isArray(cache.items)) return cache;
+ } catch (e) { /* storage unavailable; fall back to live fetch */ }
+ return null;
+}
+
+function writePlannerCache(items, lastFullRefresh, activeCourseIds) {
+ try {
+ // Fire-and-forget; guard both sync throws and (in MV3) promise
+ // rejection, e.g. a quota error — losing the cache is non-fatal.
+ const p = chrome.storage.local.set({ [PLANNER_CACHE_KEY]: { items, lastFullRefresh, activeCourseIds } });
+ if (p && typeof p.catch === "function") p.catch(() => {});
+ } catch (e) { /* cache write failure is non-fatal */ }
+}
+
+// Fetches every page of /api/v1/planner/items with a due date on/after
+// `startDate`, following the Link "next" headers until exhausted. Uses the
+// same session/headers as getData.
+async function fetchPlannerItemsSince(startDate) {
const allItems = [];
- let url = `${domain}/api/v1/planner/items?start_date=${PLANNER_START_DATE}&per_page=100`;
+ let url = `${domain}/api/v1/planner/items?start_date=${startDate}&per_page=100`;
for (let page = 0; page < PLANNER_MAX_PAGES && url; page++) {
let response;
let data;
@@ -7036,14 +7135,140 @@ async function getAllPlannerItems() {
// Deep-clone via JSON to unwrap Firefox Xray objects so nested props
// are mutable (same as getData).
try {
- data = JSON.parse(JSON.stringify(data));
- } catch (_) { /* keep original */ }
- allItems.push(...data);
+ allItems.push(...JSON.parse(JSON.stringify(data)));
+ } catch (_) {
+ allItems.push(...data);
+ }
url = getNextPageUrl(response.headers.get("Link"));
}
return allItems;
}
+// Merges a fresh window fetch into the cached list. Cached items whose due
+// date falls inside the fetched window are replaced wholesale (catches new,
+// changed, and removed items); older cached items are kept as-is.
+function mergePlannerItems(cached, fetched, windowStartMs) {
+ const byKey = new Map();
+ for (const item of cached) {
+ const due = new Date(item.plannable_date).getTime();
+ if (due >= windowStartMs) continue; // superseded by the fresh fetch
+ byKey.set(plannerItemKey(item), item);
+ }
+ for (const item of fetched) byKey.set(plannerItemKey(item), item);
+ return sortAndTrimPlannerItems([...byKey.values()]);
+}
+
+// Drops items older than the lookback window and returns the list sorted by
+// due date ascending (the order the rest of the extension expects).
+function sortAndTrimPlannerItems(items) {
+ const cutoff = Date.now() - PLANNER_LOOKBACK_DAYS * 86400000;
+ const trimmed = items.filter(item => new Date(item.plannable_date).getTime() >= cutoff);
+ trimmed.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date));
+ return trimmed;
+}
+
+// Cheap change signature so consumers only re-render when something they
+// display actually moved: due date, submitted/graded/complete state, or an
+// announcement's read state.
+function plannerFingerprint(items) {
+ return items.map(item =>
+ `${plannerItemKey(item)}:${item.plannable_date}:${item.submissions?.submitted ? 1 : 0}:${item.submissions?.graded ? 1 : 0}:${item.planner_override?.marked_complete ? 1 : 0}:${item.plannable?.read_state ?? ""}`
+ ).join("|");
+}
+
+// Entry point for the planner data. Resolves instantly from cache when
+// present; otherwise does one bounded (1-year) fetch on the critical path so
+// the to-do list isn't empty on first run, then caches the result. In both
+// cases items are limited to the student's currently-active courses.
+async function loadPlannerItems() {
+ const cache = await readPlannerCache();
+ if (cache) {
+ schedulePlannerRefresh(cache);
+ // Serve instantly, filtered with the last known enrollment list;
+ // the background refresh below brings that list up to date.
+ return filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds);
+ }
+ // First run (no cache yet): fetch the bounded lookback and the active
+ // enrollment list in parallel, then cache the filtered result.
+ const [items, courseIds] = await Promise.all([
+ fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)),
+ fetchActiveCourseIds(),
+ ]);
+ const filtered = filterPlannerItemsByActiveCourses(
+ sortAndTrimPlannerItems(items), courseIds
+ );
+ writePlannerCache(filtered, Date.now(), courseIds ? [...courseIds] : null);
+ return filtered;
+}
+
+// Background cache refresh: a small recent window on every load (catches new
+// items and recent changes), plus a full lookback walk at most once every
+// PLANNER_FULL_REFRESH_DAYS (catches state changes on older items, e.g. a
+// months-old assignment finally being submitted). The active-enrollment list
+// is refreshed on every pass so concluded classes drop out promptly rather
+// than waiting for the weekly walk. Runs while the tab is idle so it never
+// competes with page load.
+function schedulePlannerRefresh(cache) {
+ const fullRefreshDue = !cache.lastFullRefresh ||
+ (Date.now() - cache.lastFullRefresh > PLANNER_FULL_REFRESH_DAYS * 86400000);
+ const run = async () => {
+ try {
+ const before = plannerFingerprint(
+ filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds)
+ );
+ const courseIds = await fetchActiveCourseIds();
+ // On enrollment-fetch failure, fall back to the cached list.
+ const active = courseIds ?? (cache.activeCourseIds ?? null);
+ const idsToStore = courseIds ? [...courseIds] : (cache.activeCourseIds ?? null);
+ let merged;
+ if (fullRefreshDue) {
+ merged = filterPlannerItemsByActiveCourses(
+ sortAndTrimPlannerItems(
+ await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS))
+ ),
+ active
+ );
+ writePlannerCache(merged, Date.now(), idsToStore);
+ } else {
+ const windowStart = plannerDateDaysAgo(PLANNER_WINDOW_DAYS);
+ const fetched = await fetchPlannerItemsSince(windowStart);
+ merged = filterPlannerItemsByActiveCourses(
+ mergePlannerItems(cache.items, fetched, Date.parse(windowStart)),
+ active
+ );
+ writePlannerCache(merged, cache.lastFullRefresh, idsToStore);
+ }
+ if (plannerFingerprint(merged) !== before) refreshPlannerConsumers(merged);
+ } catch (e) {
+ console.warn("planner refresh failed", e);
+ }
+ };
+ if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(() => run(), { timeout: 20000 });
+ } else {
+ setTimeout(run, 5000);
+ }
+}
+
+// Re-renders everything that displays planner items after the background
+// refresh produced new data. Mirrors the option-change handlers so all the
+// existing consumers re-attach to the updated promise.
+function refreshPlannerConsumers(items) {
+ assignments = Promise.resolve(items);
+ updateReminders();
+ if (options.assignments_due === true || options.better_todo === true) {
+ cardAssignments = preloadAssignmentEls();
+ loadCardAssignments();
+ }
+ if (options.better_todo && document.getElementById("better-todo-main")) {
+ moreAnnouncementCount = 0;
+ moreAssignmentCount = 0;
+ moreCompletedCount = 0;
+ clearTodoList();
+ createTodoSections(document.querySelector("#canvasrefined-todo-list"));
+ }
+}
+
// Extracts the rel="next" URL from a Canvas pagination Link header, or
// returns null when on the last page.
function getNextPageUrl(linkHeader) {
diff --git a/js/popup.js b/js/popup.js
index cfe4ce9..5e60da1 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -1249,8 +1249,94 @@ function setup() {
// activate storage reset button
document.querySelector("#storage-reset-btn").addEventListener("click", () => {
chrome.storage.sync.set(defaultOptions["sync"]);
+ updateStorageUsage();
});
+ // activate planner cache clear button (Report issue tab). Clears the
+ // cached planner items locally, then tells any open Canvas tabs to
+ // re-fetch fresh data so the clear takes effect without a refresh.
+ // Keep the key in sync with PLANNER_CACHE_KEY in content.js.
+ document.querySelector("#planner-cache-clear-btn").addEventListener("click", async () => {
+ displayAlert(false, "Planner cache cleared.");
+ try {
+ await chrome.storage.local.remove("planner_cache_v1");
+ } catch (e) { /* nothing stored yet */ }
+ await sendFromPopup("clearPlannerCache");
+ updateStorageUsage();
+ });
+
+ // ---- Storage usage display (Report issue tab) ----
+ // Shows how much space the extension's settings, planner cache, and
+ // other local data take, plus the storage quota available to it.
+ function formatStorageBytes(bytes) {
+ if (bytes == null) return "unknown";
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / 1048576).toFixed(2) + " MB";
+ }
+
+ // getBytesInUse is exact but not implemented everywhere (older Firefox),
+ // so fall back to measuring the serialized data as a Blob.
+ async function storageBytesInUse(area, keys) {
+ try {
+ const bytes = await area.getBytesInUse(keys ?? null);
+ if (typeof bytes === "number") return bytes;
+ } catch (e) { /* not supported */ }
+ try {
+ const data = await area.get(keys ?? null);
+ return new Blob([JSON.stringify(data)]).size;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ async function updateStorageUsage() {
+ const set = (id, text) => {
+ const el = document.querySelector(id);
+ if (el) el.textContent = text;
+ };
+
+ // Sync settings vs the 100 KB chrome.storage.sync quota.
+ const syncBytes = await storageBytesInUse(chrome.storage.sync);
+ set("#storage-usage-sync", syncBytes == null ? "unknown" : formatStorageBytes(syncBytes) + " of 100 KB quota");
+
+ // Planner cache: size plus item count, so users can see what the
+ // to-do cache actually costs (and that clearing it worked).
+ const cacheBytes = await storageBytesInUse(chrome.storage.local, "planner_cache_v1");
+ let cacheText = "empty";
+ if (cacheBytes) {
+ try {
+ const result = await chrome.storage.local.get("planner_cache_v1");
+ const count = result?.["planner_cache_v1"]?.items?.length;
+ cacheText = formatStorageBytes(cacheBytes) + (typeof count === "number" ? ` (${count} items)` : "");
+ } catch (e) {
+ cacheText = formatStorageBytes(cacheBytes);
+ }
+ }
+ set("#storage-usage-cache", cacheText);
+
+ // Everything else in local storage (settings the extension keeps
+ // locally, state like the analytics toggle, etc.).
+ const localBytes = await storageBytesInUse(chrome.storage.local);
+ if (localBytes == null) {
+ set("#storage-usage-local", "unknown");
+ } else {
+ const other = cacheBytes != null ? Math.max(localBytes - cacheBytes, 0) : null;
+ set("#storage-usage-local", other == null ? formatStorageBytes(localBytes) : formatStorageBytes(other));
+ }
+
+ // Quota available to the extension. With the unlimitedStorage
+ // permission this is a large slice of free disk space, so it reads
+ // big — that is expected.
+ try {
+ const est = await navigator.storage.estimate();
+ if (est && est.quota) {
+ set("#storage-usage-quota", formatStorageBytes(est.quota) + " available");
+ }
+ } catch (e) { /* estimate unsupported */ }
+ }
+ updateStorageUsage();
+
// activate custom url input
document.querySelector('#customDomain').addEventListener('input', function () {
let domains = this.value.split(",");
diff --git a/manifest.json b/manifest.json
index a4b829c..9982ad2 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Canvas Refined",
"description": "Even More Feature packed extension for Canvas.",
- "version": "7.0.0",
+ "version": "7.0.1",
"icons": {
"16": "icon/icon-16.png",
"32": "icon/icon-32.png",
@@ -30,7 +30,7 @@
"run_at": "document_start"
}
],
- "permissions": ["storage"],
+ "permissions": ["storage", "unlimitedStorage"],
"default_locale": "en",
"browser_specific_settings": {
"gecko": {