From 2db66dbaed664477d138521b01c327db4f4cce97 Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Tue, 1 Sep 2026 19:28:23 -0700 Subject: [PATCH] fixed todo list again --- js/content.js | 151 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 26 deletions(-) diff --git a/js/content.js b/js/content.js index 2ed885d..dde20a5 100644 --- a/js/content.js +++ b/js/content.js @@ -2515,7 +2515,7 @@ function renderProgressRings(container, scopedData) { const entries = Object.keys(groups).map(cid => { const arr = groups[cid]; - const completed = arr.filter(it => (it.submissions?.submitted || it.planner_override?.marked_complete)).length; + const completed = arr.filter(it => (it.submissions?.submitted || it.planner_override?.marked_complete) && !isPinnedIncomplete(it)).length; return { courseId: cid, total: arr.length, completed }; }).filter(e => e.total > 0); @@ -3168,13 +3168,20 @@ async function createTodoSections(location) { }); announcements = displayData.filter(item => item.plannable_type == "announcement"); - assignmentsDue = displayData.filter(item => isTodoTaskType(item) && !item.submissions?.submitted && !item.planner_override?.marked_complete); - completed = displayData.filter(item => isTodoTaskType(item) && (item.submissions?.submitted || item.planner_override?.marked_complete)); + // Pinned items (locally forced incomplete, e.g. a submitted assignment + // the user sent back to Tasks) always count as due; everything else is + // due only when neither submitted nor marked complete. + assignmentsDue = displayData.filter(item => isTodoTaskType(item) && ((!item.submissions?.submitted && !item.planner_override?.marked_complete) || isPinnedIncomplete(item))); + completed = displayData.filter(item => isTodoTaskType(item) && (item.submissions?.submitted || item.planner_override?.marked_complete) && !isPinnedIncomplete(item)); // 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. Only the Tasks tab is affected (announcements and // the completed tab always show everything). - assignmentsDue = applyTodoTimeframe(assignmentsDue); + // The timeframe filter only applies to the Tasks tab, but a pinned item + // must never be dropped from both tabs: exclude pins from the cutoff + // filter so a "sent back" old item still shows up under Tasks. + const pinnedItems = assignmentsDue.filter(item => isPinnedIncomplete(item)); + assignmentsDue = applyTodoTimeframe(assignmentsDue.filter(item => !isPinnedIncomplete(item))).concat(pinnedItems); // console.log("assignments", assignmentsDue); // console.log("announcements", announcements); // console.log("completed", completed); @@ -3478,6 +3485,11 @@ function populateAssignments(iscompleted = false) { } return new Date(b.plannable_date) - new Date(a.plannable_date); }); + } else { + // Keep a stable chronological order (overdue first) so the visible + // item budget below counts from due today onward. The planner API + // normally returns ascending order already; this makes it guaranteed. + assignments.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date)); } let assignmentCount = 0; @@ -3502,8 +3514,12 @@ function populateAssignments(iscompleted = false) { let assignment const targetContainer = domContainers[dueGroup]; - assignmentCount++; - let isHidden = assignmentCount > maxElements; + // Overdue items are always shown and don't consume the visible-item + // budget: the count starts at items due today. (On the Completed tab, + // whose groups are Graded/Ungraded, every item still counts.) + const isOverdue = !iscompleted && dueGroup === -1; + if (!isOverdue) assignmentCount++; + let isHidden = !isOverdue && assignmentCount > maxElements; if (targetContainer) { if (!isHidden) { @@ -3545,6 +3561,10 @@ function populateAssignments(iscompleted = false) { const removeIcons = options.todo_remove_icons === true; const isCustomTask = item.plannable_type == "planner_note" || item.planner_override?.custom === true; + // True when Canvas has a real submission for this item: the Completed + // list shows it regardless of the checkmark state, so the toggle is + // disabled for it (see the checkmark listener below). + const wasSubmitted = item.submissions?.submitted === true; const taskHref = isCustomTask ? customTaskHref(item) : (domain + item.html_url); const editButtonSvg = isCustomTask ? `` @@ -3577,7 +3597,7 @@ function populateAssignments(iscompleted = false) { ${convertToDueDate(item.plannable_date)} ${editButtonSvg} - + @@ -3587,9 +3607,13 @@ function populateAssignments(iscompleted = false) { `; + // The checkmark reflects the tab it is rendered in: on the Tasks tab it + // marks the item complete; on the Completed tab it makes it incomplete + // again (for genuinely submitted items this sets a local pin, since + // Canvas has no way to un-submit). assignment.querySelector(".better-todo-assignment-checkmark").addEventListener("click", () => { - console.log("marking ", item.plannable.title, " as complete"); - markAs(item, assignment.firstElementChild); + console.log("marking ", item.plannable.title, iscompleted ? "as incomplete" : "as complete"); + markAs(item, assignment.firstElementChild, !iscompleted); }); const editBtn = assignment.querySelector(".better-todo-assignment-edit"); if (editBtn) { @@ -3805,9 +3829,38 @@ function createConfettiBurst(targetElement, opts = {}) { } } -function markAs(item, element) { +// --- Local "incomplete" pins ----------------------------------------------- +// Canvas records real submissions server-side and offers no API to un-submit, +// so an assignment the user actually handed in can never leave the Completed +// list via the planner override. For those items the checkmark instead sets a +// local pin that forces the item back into the Tasks list. The pin lives in +// chrome.storage.sync and clears when the item is checked off again. +function todoPinKey(item) { + return item.plannable_type + "_" + item.plannable_id; +} +function isPinnedIncomplete(item) { + return options.todo_force_incomplete?.[todoPinKey(item)] === true; +} +function setPinnedIncomplete(item, pinned) { + const pins = Object.assign({}, options.todo_force_incomplete || {}); + if (pinned) pins[todoPinKey(item)] = true; + else delete pins[todoPinKey(item)]; + options.todo_force_incomplete = pins; + chrome.storage.sync.set({ todo_force_incomplete: pins }); +} + +function markAs(item, element, makeComplete) { const csrfToken = CSRFtoken(); - const completeState = item.planner_override ? !item.planner_override.marked_complete : true; + const completeState = makeComplete; + + // Checking an item off clears any local incomplete pin; unchecking a + // genuinely submitted item sets one, since the submission itself can't + // be undone server-side. + if (completeState) { + if (isPinnedIncomplete(item)) setPinnedIncomplete(item, false); + } else if (item.submissions?.submitted) { + setPinnedIncomplete(item, true); + } // --- Optimistic UI --- // Canvas's /planner/overrides endpoint occasionally returns 400 (Bad @@ -3853,32 +3906,78 @@ function markAs(item, element) { }, 400); // --- Persistence (background, best-effort) --- - // A 400/non-OK response is treated as a soft success because Canvas often - // completes the override server-side anyway; we already reflected the - // change in the UI, so just log it. - fetch(domain + "/api/v1/planner/overrides" + (item.planner_override && item.planner_override.id ? "/" + item.planner_override.id : ""), { - method: item.planner_override && item.planner_override.id ? "PUT" : "POST", + // Toggle the planner override server-side. The override id returned by a + // successful POST/PUT is stored on the in-memory item so subsequent + // toggles PUT (update) instead of POSTing a duplicate. If a POST still + // collides with an existing override (400 "already exists"), we look up + // the real id and retry with a PUT. Only if all of that fails is the + // response treated as a soft success: the UI already reflects the + // intended state, so we just log it. + const sendOverride = (override) => fetch(domain + "/api/v1/planner/overrides" + (override && override.id ? "/" + override.id : ""), { + method: override && override.id ? "PUT" : "POST", headers: { - "content-type":"application/json", - "accept":"application/json", + "content-type": "application/json", + "accept": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify({ - id: item.planner_override && item.planner_override.id ? item.planner_override.id : null, + id: override && override.id ? override.id : null, marked_complete: completeState, plannable_id: item.plannable_id, plannable_type: item.plannable_type }) - }) - .then(resp => { + }); + + sendOverride(item.planner_override) + .then(async resp => { if (resp.ok) { + // Capture the override id from the response. A POST creates the + // override server-side with an id we don't know yet; without + // storing it, every later toggle on this item would POST again and + // collide with the existing override (400 "already exists"), so + // unchecking would never actually persist. + try { + const saved = await resp.json(); + if (saved && saved.id) { + item.planner_override = item.planner_override || {}; + item.planner_override.id = saved.id; + } + } catch (e) { /* no JSON body; id stays unknown */ } console.log("marked as complete"); - } else { - // Non-OK (e.g. 400) is logged but not surfaced: the UI already - // reflects the intended state, and Canvas typically applies the - // override server-side regardless. - console.warn("planner override request returned", resp.status, "— UI already updated optimistically"); + return; } + // A POST can return 400 because an override already exists server-side + // under an id we never learned (e.g. it was created earlier without the + // response being captured). Look up the user's overrides, find the one + // for this item, and retry with a PUT so the toggle actually persists. + if (!(item.planner_override && item.planner_override.id)) { + try { + const listResp = await fetch(domain + "/api/v1/planner/overrides", { + headers: { "accept": "application/json" } + }); + if (listResp.ok) { + const overrides = await listResp.json(); + const match = (Array.isArray(overrides) ? overrides : []).find(o => + o && + String(o.plannable_id) === String(item.plannable_id) && + String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase()); + if (match && match.id) { + item.planner_override = item.planner_override || {}; + item.planner_override.id = match.id; + const retry = await sendOverride(item.planner_override); + if (!retry.ok) { + console.warn("planner override PUT retry returned", retry.status); + } + return; + } + } + } catch (e) { + console.error("error recovering planner override id", e); + } + } + // Still non-OK with nothing to recover: logged but not surfaced, the + // UI already reflects the intended state. + console.warn("planner override request returned", resp.status, "— UI already updated optimistically"); }) .catch(err => console.error("error marking as complete", err));