fixed todo list again

This commit is contained in:
Guy Sandler 2026-09-01 19:28:23 -07:00
parent 3e6a5927d7
commit 2db66dbaed

View File

@ -2515,7 +2515,7 @@ function renderProgressRings(container, scopedData) {
const entries = Object.keys(groups).map(cid => { const entries = Object.keys(groups).map(cid => {
const arr = groups[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 }; return { courseId: cid, total: arr.length, completed };
}).filter(e => e.total > 0); }).filter(e => e.total > 0);
@ -3168,13 +3168,20 @@ async function createTodoSections(location) {
}); });
announcements = displayData.filter(item => item.plannable_type == "announcement"); announcements = displayData.filter(item => item.plannable_type == "announcement");
assignmentsDue = displayData.filter(item => isTodoTaskType(item) && !item.submissions?.submitted && !item.planner_override?.marked_complete); // Pinned items (locally forced incomplete, e.g. a submitted assignment
completed = displayData.filter(item => isTodoTaskType(item) && (item.submissions?.submitted || item.planner_override?.marked_complete)); // 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 // 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 // popup. Read the current value each render so popup changes apply on
// the next render. Only the Tasks tab is affected (announcements and // the next render. Only the Tasks tab is affected (announcements and
// the completed tab always show everything). // 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("assignments", assignmentsDue);
// console.log("announcements", announcements); // console.log("announcements", announcements);
// console.log("completed", completed); // console.log("completed", completed);
@ -3478,6 +3485,11 @@ function populateAssignments(iscompleted = false) {
} }
return new Date(b.plannable_date) - new Date(a.plannable_date); 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; let assignmentCount = 0;
@ -3502,8 +3514,12 @@ function populateAssignments(iscompleted = false) {
let assignment let assignment
const targetContainer = domContainers[dueGroup]; const targetContainer = domContainers[dueGroup];
assignmentCount++; // Overdue items are always shown and don't consume the visible-item
let isHidden = assignmentCount > maxElements; // 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 (targetContainer) {
if (!isHidden) { if (!isHidden) {
@ -3545,6 +3561,10 @@ function populateAssignments(iscompleted = false) {
const removeIcons = options.todo_remove_icons === true; const removeIcons = options.todo_remove_icons === true;
const isCustomTask = item.plannable_type == "planner_note" || item.planner_override?.custom === 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 taskHref = isCustomTask ? customTaskHref(item) : (domain + item.html_url);
const editButtonSvg = isCustomTask const editButtonSvg = isCustomTask
? `<svg class="better-todo-assignment-edit" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:15px;height:15px;position:absolute;top:18px;right:5px;opacity:0.3;transition:all .3s ease;cursor:pointer;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.3'" title="Edit this custom task"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" stroke="var(--bctext-0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>` ? `<svg class="better-todo-assignment-edit" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:15px;height:15px;position:absolute;top:18px;right:5px;opacity:0.3;transition:all .3s ease;cursor:pointer;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.3'" title="Edit this custom task"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" stroke="var(--bctext-0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>`
@ -3577,7 +3597,7 @@ function populateAssignments(iscompleted = false) {
<span style="color:var(--bctext-0);font-size:12px;margin-top:-5px;">${convertToDueDate(item.plannable_date)}</span> <span style="color:var(--bctext-0);font-size:12px;margin-top:-5px;">${convertToDueDate(item.plannable_date)}</span>
</div> </div>
${editButtonSvg} ${editButtonSvg}
<svg class="better-todo-assignment-checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:15px;height:15px;position:absolute;top:0px;right:5px;opacity:0.3;transition:all .3s ease;cursor:pointer;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.3'"> <svg class="better-todo-assignment-checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:15px;height:15px;position:absolute;top:0px;right:5px;opacity:0.3;transition:all .3s ease;cursor:pointer;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.3'"${(iscompleted && wasSubmitted) ? " title=\"Submitted to Canvas — clicking sends it back to Tasks (locally)\"" : ""}>
<g id="SVGRepo_bgCarrier" stroke-width="0"></g> <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g> <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier"> <g id="Interface / Checkbox_Check"> <g id="SVGRepo_iconCarrier"> <g id="Interface / Checkbox_Check">
@ -3587,9 +3607,13 @@ function populateAssignments(iscompleted = false) {
</div> </div>
</div> </div>
`; `;
// 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", () => { assignment.querySelector(".better-todo-assignment-checkmark").addEventListener("click", () => {
console.log("marking ", item.plannable.title, " as complete"); console.log("marking ", item.plannable.title, iscompleted ? "as incomplete" : "as complete");
markAs(item, assignment.firstElementChild); markAs(item, assignment.firstElementChild, !iscompleted);
}); });
const editBtn = assignment.querySelector(".better-todo-assignment-edit"); const editBtn = assignment.querySelector(".better-todo-assignment-edit");
if (editBtn) { 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 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 --- // --- Optimistic UI ---
// Canvas's /planner/overrides endpoint occasionally returns 400 (Bad // Canvas's /planner/overrides endpoint occasionally returns 400 (Bad
@ -3853,32 +3906,78 @@ function markAs(item, element) {
}, 400); }, 400);
// --- Persistence (background, best-effort) --- // --- Persistence (background, best-effort) ---
// A 400/non-OK response is treated as a soft success because Canvas often // Toggle the planner override server-side. The override id returned by a
// completes the override server-side anyway; we already reflected the // successful POST/PUT is stored on the in-memory item so subsequent
// change in the UI, so just log it. // toggles PUT (update) instead of POSTing a duplicate. If a POST still
fetch(domain + "/api/v1/planner/overrides" + (item.planner_override && item.planner_override.id ? "/" + item.planner_override.id : ""), { // collides with an existing override (400 "already exists"), we look up
method: item.planner_override && item.planner_override.id ? "PUT" : "POST", // 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: { headers: {
"content-type":"application/json", "content-type": "application/json",
"accept":"application/json", "accept": "application/json",
"X-CSRF-Token": csrfToken "X-CSRF-Token": csrfToken
}, },
body: JSON.stringify({ 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, marked_complete: completeState,
plannable_id: item.plannable_id, plannable_id: item.plannable_id,
plannable_type: item.plannable_type plannable_type: item.plannable_type
}) })
}) });
.then(resp => {
sendOverride(item.planner_override)
.then(async resp => {
if (resp.ok) { 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"); console.log("marked as complete");
} else { return;
// 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");
} }
// 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)); .catch(err => console.error("error marking as complete", err));