From d40fc01b42623563aa17e6a6fbaba30be077063c Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Sat, 22 Aug 2026 17:15:57 -0700 Subject: [PATCH] new progress displays --- css/popup.css | 6 +- html/popup.html | 12 +- js/background.js | 2 +- js/content.js | 596 +++++++++++++++++++++++++++++++++++------------ js/popup.js | 23 +- 5 files changed, 489 insertions(+), 150 deletions(-) diff --git a/css/popup.css b/css/popup.css index b2d44a1..904c4ba 100644 --- a/css/popup.css +++ b/css/popup.css @@ -1,4 +1,4 @@ -:root {--inputbg: #1010104f;--containerbg: #00000047;--inputbg: #ffffff14;} +:root {--inputbg: #1010104f;--containerbg: #00000047;--inputbg: #ffffff14;color-scheme: dark;} html, body{width:465px;max-width:465px;overflow-x:hidden;box-sizing:border-box} body{min-height:100%;height:auto;font-family: 'Wix Madefor Text', sans-serif;color: #e2e2e2;background: #161616;transition: .3s height} button {font-family: 'Wix Madefor Text', sans-serif} @@ -65,6 +65,10 @@ h2 {margin-top: 20px;font-weight:600} .custom-card-hide {display: flex; align-items: center;} .option-name {font-weight: 600} .sub-text {font-size: 12px;color: #c7c7c7} +#todo_progress_rings {font-size: 12px;background: var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;color-scheme:dark;} +#todo_progress_rings option {background:#1e1e1e;color:#e2e2e2;} +#todo_progress_rings option:checked, +#todo_progress_rings option:hover {background:#2e2e2e;color:#fff;} #advanced-settings, #gpa-bounds-btn, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;} .back-btn {width: 120px;height:100%;margin-top:0} .big-button {border: none;border-radius: 7px;background: var(--containerbg);color: #fff;font-family: inherit;padding: 12px 10px;font-weight:600} diff --git a/html/popup.html b/html/popup.html index 0526500..714fac2 100644 --- a/html/popup.html +++ b/html/popup.html @@ -236,9 +236,15 @@ -
- - +
+ +
diff --git a/js/background.js b/js/background.js index f8bebbf..01d0c87 100644 --- a/js/background.js +++ b/js/background.js @@ -81,7 +81,7 @@ chrome.runtime.onInstalled.addListener(function () { "tab_icons": false, "todo_hide_feedback": false, "todo_full_height": true, - "todo_progress_rings": true, + "todo_progress_rings": "rings", "todo_confetti": true, "device_dark": false, "cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 }, diff --git a/js/content.js b/js/content.js index 1cc1149..15bf715 100644 --- a/js/content.js +++ b/js/content.js @@ -711,7 +711,7 @@ function applyOptionsChanges(changes) { // toggle progress rings immediately const placeholder = document.getElementById("better-todo-progress-placeholder"); if (!placeholder) break; - if (changes[key].newValue === true || changes[key].newValue === undefined) { + if (progressRingsEnabled()) { if (typeof assignments?.then === 'function') { assignments.then(data => { const courseId = getCurrentCourseId(); @@ -1330,8 +1330,27 @@ function updateIndicator(element) { } // better todo html betterTodoFilter = "tasks"; +// null = show every class; a string courseId = only that class's tasks. +let betterTodoProgressFilter = null; let domContainers = {}; +// true when `courseId` is the dimmed-out class because another class is selected. +function progressFilterDim(courseId) { + return betterTodoProgressFilter != null && String(courseId) !== String(betterTodoProgressFilter); +} +// Make an element filter the todo list to one class on click. A no-op on +// course pages (where only one class is in scope anyway); toggles off when the +// active class is clicked again. +function attachProgressFilterClick(el, courseId) { + if (getCurrentCourseId() != null) { el.style.cursor = ''; el.onclick = null; return; } + el.style.cursor = 'pointer'; + el.onclick = () => { + betterTodoProgressFilter = (String(betterTodoProgressFilter) === String(courseId)) ? null : String(courseId); + const loc = document.querySelector("#canvasrefined-todo-list"); + if (loc) { clearTodoList(); createTodoSections(loc); } + }; +} + function formatDateForInput(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -1345,161 +1364,95 @@ function formatTimeForInput(date) { return `${hours}:${minutes}`; } -function renderProgressRings(container, scopedData) { - const allAssignments = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note")); +/* Progress display mode, stored in options.todo_progress_rings. + Modes: "none", "rings", "rainbow", "lines", "line". + Legacy booleans normalize: true/undefined -> rings, false -> none. */ +function getProgressRingMode() { + const v = options.todo_progress_rings; + if (v === false) return "none"; + if (v === true || v === undefined || v === null) return "rings"; + const allowed = ["none", "rings", "rainbow", "lines", "line"]; + if (allowed.includes(v)) return v; + // migrate unreleased string values from the prior experimental set + if (v === "per_course") return "rings"; + if (v === "combined" || v === "segmented") return "line"; + return "rings"; +} - // exclude items older than one month - const oneMonthAgo = Date.now() - 1000 * 60 * 60 * 24 * 30; - const recentAssignments = allAssignments.filter(item => { - const dateStr = item.plannable_date || item.todo_date || item.plannable?.due_at || item.plannable?.plannable_date; - if (!dateStr) return true; // keep items without a date - const ts = Date.parse(dateStr); - if (Number.isNaN(ts)) return true; - return ts >= oneMonthAgo; - }); - const groups = {}; - allAssignments.forEach(item => { - const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal"); - groups[cid] = groups[cid] || []; - groups[cid].push(item); - }); +function progressRingsEnabled() { + return getProgressRingMode() !== "none"; +} - 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; - return { courseId: cid, total: arr.length, completed }; - }).filter(e => e.total > 0); +function courseRingColor(courseId, idx) { + return options.custom_cards_3?.[String(courseId)]?.color + || options.custom_cards_3?.[courseId]?.color + || `hsl(${(idx * 60) % 360} 70% 50%)`; +} - if (!entries.length) { - container.innerHTML = ""; - return; - } +function courseRingLabel(courseId) { + const card = options.custom_cards?.[String(courseId)] || options.custom_cards?.[courseId]; + return card?.default || `Course ${courseId}`; +} - // sort by total desc and limit rings to 6 - entries.sort((a, b) => b.total - a.total); - const shown = entries.slice(0, 6); - - const totalAll = shown.reduce((s, e) => s + e.total, 0); - const completedAll = shown.reduce((s, e) => s + e.completed, 0); - const percent = totalAll === 0 ? 0 : Math.round((completedAll / totalAll) * 100); - - // build SVG rings with visible gaps and a larger central hole. - // calculate available width from the container so the outer ring is slightly inset - const containerWidth = (container.clientWidth || 240); - const maxSize = Math.min(280, Math.floor(containerWidth * 0.99)); - const size = maxSize; // svg square size +// Mode "rings": concentric rings, one per course, each filled by completion. +function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) { + const containerWidth = wrapper.clientWidth || 240; + const size = Math.min(280, Math.floor(containerWidth * 0.99)); const cx = size / 2; const cy = size / 2; - let svgInner = ""; - - const ringCount = shown.length; - const stroke = 8; // ring thickness (thinner) - const gap = 4; // visible gap between rings (reduced) - const decrement = stroke + gap; // radius difference per ring ensures gap - - // make outer ring extend closer to container edges by using a small padding const padding = 2; - const startRadius = Math.floor((size / 2) - padding - (stroke / 2)); + const outerRadius = Math.floor((size / 2) - padding); - // ensure radii stay positive; if too small, reduce stroke/gap - const minCenterRadius = 28; // minimum desired central hole radius - const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius; - let adjustFactor = 1; - if (requiredSpace > startRadius) { - // scale down decrement to fit - adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement); - } - - shown.forEach((entry, idx) => { - const effectiveDecrement = Math.max(1, Math.floor(decrement * adjustFactor)); - const radius = startRadius - idx * effectiveDecrement; - if (radius <= 0) return; - const circumference = 2 * Math.PI * radius; - const prog = entry.total === 0 ? 0 : (entry.completed / entry.total); - const color = options.custom_cards_3?.[String(entry.courseId)]?.color || options.custom_cards_3?.[entry.courseId]?.color || `hsl(${(idx * 60) % 360} 70% 50%)`; - - // background ring (faded course color) - svgInner += ``; - // progress ring (rotate -90 to start at top) - initialize empty and animate to target - const dasharrayVal = circumference.toFixed(3); - const dashoffsetTarget = (circumference * (1 - prog)).toFixed(3); - // start with full offset (empty) and store target in data attribute; we'll animate after inserting into DOM - svgInner += ``; - }); - - // center overlay text positioned inside the hole - // Reuse existing elements when possible to avoid DOM replacement flicker - let wrapper = container.querySelector('.canvasrefined-progress-wrapper'); - if (!wrapper) { - wrapper = document.createElement('div'); - wrapper.className = 'canvasrefined-progress-wrapper'; - wrapper.style.display = 'flex'; - wrapper.style.flexDirection = 'column'; - wrapper.style.alignItems = 'center'; - wrapper.style.position = 'relative'; - container.appendChild(wrapper); - } - - // svg container let svg = wrapper.querySelector('svg.canvasrefined-progress-svg'); if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'canvasrefined-progress-svg'); - svg.setAttribute('width', String(size)); - svg.setAttribute('height', String(size)); - svg.setAttribute('viewBox', `0 0 ${size} ${size}`); svg.style.display = 'block'; wrapper.appendChild(svg); - } else { - svg.setAttribute('width', String(size)); - svg.setAttribute('height', String(size)); - svg.setAttribute('viewBox', `0 0 ${size} ${size}`); } + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('viewBox', `0 0 ${size} ${size}`); - // ensure overlay text exists let overlay = wrapper.querySelector('.canvasrefined-progress-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'canvasrefined-progress-overlay'; - overlay.style.position = 'absolute'; - overlay.style.left = '0'; - overlay.style.top = '0'; - overlay.style.width = '100%'; - overlay.style.height = '100%'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.pointerEvents = 'none'; + overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;'; const textWrap = document.createElement('div'); - textWrap.style.textAlign = 'center'; - textWrap.style.color = 'var(--bctext-0)'; - textWrap.innerHTML = `
${percent}%
${completedAll}/${totalAll} done
`; + textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);'; + textWrap.innerHTML = `
`; overlay.appendChild(textWrap); wrapper.appendChild(overlay); - } else { - const pc = overlay.querySelector('.canvasrefined-progress-percent'); - const cnt = overlay.querySelector('.canvasrefined-progress-count'); - if (pc) pc.textContent = `${percent}%`; - if (cnt) cnt.textContent = `${completedAll}/${totalAll} done`; + } + overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`; + overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`; + + const stroke = 8; + const gap = 4; + const decrement = stroke + gap; + const ringCount = shown.length; + const startRadius = outerRadius - stroke / 2; + const minCenterRadius = 28; + const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius; + let adjustFactor = 1; + if (requiredSpace > startRadius) { + adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement); } - // Update or create rings in-place - const existingBg = svg.querySelectorAll('circle.canvasrefined-ring-bg'); - const existingFg = svg.querySelectorAll('circle.canvasrefined-progress-ring'); - - // reuse or create circles per shown entry shown.forEach((entry, idx) => { const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor)); + if (radius <= 0) return; const circumference = 2 * Math.PI * radius; - const prog = entry.total === 0 ? 0 : (entry.completed / entry.total); - const color = options.custom_cards_3?.[String(entry.courseId)]?.color || options.custom_cards_3?.[entry.courseId]?.color || `hsl(${(idx * 60) % 360} 70% 50%)`; + const prog = entry.total === 0 ? 0 : entry.completed / entry.total; + const color = courseRingColor(entry.courseId, idx); - // background circle - let bg = svg.querySelector(`circle.canvasrefined-ring-bg[data-idx='${idx}']`); + let bg = svg.querySelector(`circle[data-idx='${idx}'][data-role='bg']`); if (!bg) { bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - bg.classList.add('canvasrefined-ring-bg'); bg.setAttribute('data-idx', String(idx)); + bg.setAttribute('data-role', 'bg'); + bg.classList.add('canvasrefined-ring-bg'); svg.appendChild(bg); } bg.setAttribute('cx', String(cx)); @@ -1509,15 +1462,18 @@ function renderProgressRings(container, scopedData) { bg.setAttribute('stroke-opacity', '0.25'); bg.setAttribute('stroke-width', String(stroke)); bg.setAttribute('fill', 'none'); + bg.removeAttribute('stroke-dasharray'); + bg.removeAttribute('stroke-dashoffset'); + bg.removeAttribute('transform'); - // foreground (progress) circle - let fg = svg.querySelector(`circle.canvasrefined-progress-ring[data-idx='${idx}']`); + let fg = svg.querySelector(`circle[data-idx='${idx}'][data-role='fg']`); const dasharrayVal = circumference.toFixed(3); - const dashoffsetTarget = (circumference * (1 - prog)).toFixed(3); + const target = (circumference * (1 - prog)).toFixed(3); if (!fg) { fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - fg.classList.add('canvasrefined-progress-ring'); fg.setAttribute('data-idx', String(idx)); + fg.setAttribute('data-role', 'fg'); + fg.classList.add('canvasrefined-progress-ring'); fg.setAttribute('stroke-linecap', 'round'); fg.setAttribute('transform', `rotate(-90 ${cx} ${cy})`); fg.setAttribute('stroke-dasharray', dasharrayVal); @@ -1532,24 +1488,367 @@ function renderProgressRings(container, scopedData) { fg.setAttribute('stroke-width', String(stroke)); fg.setAttribute('fill', 'none'); fg.setAttribute('stroke-dasharray', dasharrayVal); - fg.setAttribute('data-target', dashoffsetTarget); - - // request animation frame to set dashoffset to target (triggers transition) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - fg.setAttribute('stroke-dashoffset', dashoffsetTarget); - }); - }); + const dim = progressFilterDim(entry.courseId); + bg.style.transition = 'opacity .3s ease'; + bg.style.opacity = dim ? '0.25' : ''; + fg.style.opacity = dim ? '0.3' : ''; + requestAnimationFrame(() => requestAnimationFrame(() => fg.setAttribute('stroke-dashoffset', target))); + // transparent hit band on top so the whole ring is easy to click; + // width tracks the ring spacing so adjacent bands don't overlap. + const step = Math.max(1, Math.floor(decrement * adjustFactor)); + let hit = svg.querySelector(`circle[data-idx='${idx}'][data-role='hit']`); + if (!hit) { + hit = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + hit.setAttribute('data-idx', String(idx)); + hit.setAttribute('data-role', 'hit'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('pointer-events', 'stroke'); + svg.appendChild(hit); + } + hit.setAttribute('cx', String(cx)); + hit.setAttribute('cy', String(cy)); + hit.setAttribute('r', String(radius)); + hit.setAttribute('stroke-width', String(Math.max(stroke, step))); + attachProgressFilterClick(hit, entry.courseId); + hit.onmouseenter = () => { bg.style.opacity = '0.8'; fg.style.opacity = '0.8'; }; + hit.onmouseleave = () => { + const d = progressFilterDim(entry.courseId); + bg.style.opacity = d ? '0.25' : ''; + fg.style.opacity = d ? '0.3' : ''; + }; }); - // remove any extra existing circles const maxIdx = shown.length - 1; - svg.querySelectorAll('circle.canvasrefined-ring-bg, circle.canvasrefined-progress-ring').forEach(c => { + svg.querySelectorAll('circle').forEach(c => { const idx = parseInt(c.getAttribute('data-idx')); if (Number.isNaN(idx) || idx > maxIdx) c.remove(); }); } +// Mode "rainbow": like "rings" (one arc per class) but condensed into a top +// half-circle and colored with a rainbow palette instead of course colors. +function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent) { + const containerWidth = wrapper.clientWidth || 240; + const size = Math.min(280, Math.floor(containerWidth * 0.99)); + const cx = size / 2; + const stroke = 8; + const gap = 4; + const decrement = stroke + gap; + const ringCount = shown.length; + const pad = 2; + const outerRadius = Math.max(20, Math.floor(size / 2) - stroke / 2 - pad); + // diameter line; arcs bulge UPWARD from here (into y < baseY) + const baseY = outerRadius + stroke / 2 + pad; + const svgHeight = Math.ceil(baseY + stroke / 2 + 2); + + let svg = wrapper.querySelector('svg.canvasrefined-progress-svg'); + if (!svg) { + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'canvasrefined-progress-svg'); + svg.style.display = 'block'; + wrapper.appendChild(svg); + } + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(svgHeight)); + svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`); + + // shrink spacing if too many classes would overflow the inner radius + const minInnerRadius = 14; + const requiredSpace = (ringCount - 1) * decrement; + let adjustFactor = 1; + if (requiredSpace > outerRadius - minInnerRadius) { + adjustFactor = (outerRadius - minInnerRadius) / Math.max(1, (ringCount - 1) * decrement); + } + + shown.forEach((entry, idx) => { + const radius = outerRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor)); + if (radius <= 0) return; + // sweep-flag 1 => arc bulges UPWARD (top semicircle), drawn left -> right + const arcPath = `M ${cx - radius} ${baseY} A ${radius} ${radius} 0 0 1 ${cx + radius} ${baseY}`; + const prog = entry.total === 0 ? 0 : entry.completed / entry.total; + const color = `hsl(${Math.round((idx * 360) / Math.max(1, ringCount))},70%,55%)`; + + // track: full semicircle, faded class color + let track = svg.querySelector(`path[data-idx='${idx}'][data-role='bg']`); + if (!track) { + track = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + track.setAttribute('data-idx', String(idx)); + track.setAttribute('data-role', 'bg'); + track.setAttribute('fill', 'none'); + track.setAttribute('stroke-linecap', 'round'); + svg.appendChild(track); + } + track.setAttribute('d', arcPath); + track.setAttribute('stroke', color); + track.setAttribute('stroke-opacity', '0.25'); + track.setAttribute('stroke-width', String(stroke)); + track.removeAttribute('stroke-dasharray'); + track.removeAttribute('stroke-dashoffset'); + + // progress arc: completed portion drawn from the left, via pathLength=100 + let progArc = svg.querySelector(`path[data-idx='${idx}'][data-role='fg']`); + const targetOff = (100 * (1 - prog)).toFixed(3); + if (!progArc) { + progArc = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + progArc.setAttribute('data-idx', String(idx)); + progArc.setAttribute('data-role', 'fg'); + progArc.setAttribute('fill', 'none'); + progArc.setAttribute('stroke-linecap', 'round'); + progArc.setAttribute('pathLength', '100'); + progArc.setAttribute('stroke-dasharray', '100 100'); + progArc.setAttribute('stroke-dashoffset', '100'); // start empty + progArc.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease'; + svg.appendChild(progArc); + } + progArc.setAttribute('d', arcPath); + progArc.setAttribute('stroke', color); + progArc.setAttribute('stroke-width', String(stroke)); + progArc.setAttribute('stroke-dasharray', '100 100'); + const dim = progressFilterDim(entry.courseId); + track.style.transition = 'opacity .3s ease'; + track.style.opacity = dim ? '0.25' : ''; + progArc.style.opacity = dim ? '0.3' : ''; + requestAnimationFrame(() => requestAnimationFrame(() => progArc.setAttribute('stroke-dashoffset', targetOff))); + // transparent hit arc on top so the whole arc is easy to click. + let hit = svg.querySelector(`path[data-idx='${idx}'][data-role='hit']`); + if (!hit) { + hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('data-idx', String(idx)); + hit.setAttribute('data-role', 'hit'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('pointer-events', 'stroke'); + svg.appendChild(hit); + } + hit.setAttribute('d', arcPath); + hit.setAttribute('stroke-width', String(stroke + 6)); + attachProgressFilterClick(hit, entry.courseId); + hit.onmouseenter = () => { track.style.opacity = '0.8'; progArc.style.opacity = '0.8'; }; + hit.onmouseleave = () => { + const d = progressFilterDim(entry.courseId); + track.style.opacity = d ? '0.25' : ''; + progArc.style.opacity = d ? '0.3' : ''; + }; + }); + + // drop arcs for classes no longer shown + const maxIdx = shown.length - 1; + svg.querySelectorAll('path').forEach(p => { + const idx = parseInt(p.getAttribute('data-idx')); + if (Number.isNaN(idx) || idx > maxIdx) p.remove(); + }); + + // Overlay the percent/count text INSIDE the rainbow's semicircle hole + // (the empty bowl bounded by the INNERMOST arc) instead of beneath it or + // up among the arcs. The SVG is centered in the wrapper, so an absolutely- + // positioned overlay covering the wrapper with flex centering aligns the + // text to the SVG's horizontal center. Vertically we target the centroid of + // the innermost semicircle (a touch above the diameter line) so the text + // sits in the arc-free pocket near the bottom rather than near the apexes + // of the inner arcs, where it would overlap the rainbow strokes. + const step = Math.max(1, Math.floor(decrement * adjustFactor)); + const innerRadius = ringCount > 0 ? Math.max(1, outerRadius - (ringCount - 1) * step) : outerRadius; + const holeCenterY = baseY - (4 * innerRadius) / (3 * Math.PI) + 6; + const nudge = Math.round(holeCenterY - svgHeight / 2); + let overlay = wrapper.querySelector('.canvasrefined-progress-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'canvasrefined-progress-overlay'; + overlay.style.cssText = `position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;transform:translateY(${nudge}px);`; + overlay.innerHTML = `
`; + wrapper.appendChild(overlay); + } else { + overlay.style.transform = `translateY(${nudge}px)`; + } + overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`; + overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`; +} + +// Mode "lines": one horizontal bar per course, each with its own %. +function renderProgressLines(wrapper, shown) { + let list = wrapper.querySelector('.canvasrefined-progress-lines'); + if (!list) { + list = document.createElement('div'); + list.className = 'canvasrefined-progress-lines'; + list.style.cssText = 'display:flex;flex-direction:column;gap:8px;width:100%;box-sizing:border-box;'; + wrapper.appendChild(list); + } + while (list.children.length > shown.length) list.lastChild.remove(); + + shown.forEach((entry, idx) => { + const prog = entry.total === 0 ? 0 : entry.completed / entry.total; + const pct = Math.round(prog * 100); + const color = courseRingColor(entry.courseId, idx); + + let row = list.children[idx]; + if (!row) { + row = document.createElement('div'); + row.className = 'canvasrefined-progress-line'; + row.style.cssText = 'display:flex;flex-direction:column;gap:3px;width:100%;'; + row.innerHTML = `
`; + list.appendChild(row); + } + const labelEl = row.querySelector('.cr-pl-label'); + labelEl.textContent = courseRingLabel(entry.courseId); + labelEl.style.color = color; + row.querySelector('.cr-pl-pct').textContent = `${pct}% (${entry.completed}/${entry.total})`; + const track = row.children[1]; + track.style.background = `color-mix(in srgb, ${color} 25%, transparent)`; + const fill = row.querySelector('.cr-pl-fill'); + fill.style.background = color; + if (!fill.dataset.init) { + fill.dataset.init = '1'; + requestAnimationFrame(() => requestAnimationFrame(() => fill.style.width = `${pct}%`)); + } else { + fill.style.width = `${pct}%`; + } + row.style.transition = 'opacity .3s ease'; + row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : ''; + attachProgressFilterClick(row, entry.courseId); + row.onmouseenter = () => { row.style.opacity = '0.8'; }; + row.onmouseleave = () => { row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : ''; }; + }); +} + +// Mode "line": one horizontal bar where each class's COMPLETED portion is packed +// to the left (full course color) and its UNCOMPLETED portion to the right +// (faded course color), with no gaps between segments. Overall % shown above. +function renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent) { + let box = wrapper.querySelector('.canvasrefined-progress-oneline'); + if (!box) { + box = document.createElement('div'); + box.className = 'canvasrefined-progress-oneline'; + box.style.cssText = 'display:flex;flex-direction:column;gap:5px;width:100%;box-sizing:border-box;'; + wrapper.appendChild(box); + } + + let head = box.querySelector('.cr-ol-head'); + if (!head) { + head = document.createElement('div'); + head.className = 'cr-ol-head'; + head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--bctext-0);'; + head.innerHTML = `Overall`; + box.appendChild(head); + } + box.querySelector('.cr-ol-pct').textContent = `${percent}% (${completedAll}/${totalAll})`; + // Clicking the "Overall" header clears the active class filter. + if (getCurrentCourseId() == null) { + head.style.cursor = betterTodoProgressFilter != null ? 'pointer' : ''; + head.onclick = () => { + if (betterTodoProgressFilter == null) return; + betterTodoProgressFilter = null; + const loc = document.querySelector("#canvasrefined-todo-list"); + if (loc) { clearTodoList(); createTodoSections(loc); } + }; + } else { + head.style.cursor = ''; + head.onclick = null; + } + + let bar = box.querySelector('.cr-ol-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'cr-ol-bar'; + // position:relative so absolutely-positioned segments anchor to it + bar.style.cssText = 'position:relative;width:100%;height:14px;border-radius:999px;overflow:hidden;background:var(--bcbackground-1);'; + box.appendChild(bar); + } + + // Build the segment list left -> right: ALL completed blocks first (each + // class's full course color), then ALL remaining blocks (each class's faded + // course color). This keeps every completed segment on the left and every + // remaining segment on the right. Consecutive segments share edges, so + // there are no gaps between them. + const segs = []; + let left = 0; + // First pass: completed blocks for every class (left side). + shown.forEach((entry, idx) => { + const color = courseRingColor(entry.courseId, idx); + const doneW = totalAll === 0 ? 0 : (entry.completed / totalAll) * 100; + if (doneW > 0) { segs.push({ left, w: doneW, bg: color, courseId: entry.courseId }); left += doneW; } // completed: full color + }); + // Second pass: remaining blocks for every class (right side). + shown.forEach((entry, idx) => { + const color = courseRingColor(entry.courseId, idx); + const remW = totalAll === 0 ? 0 : ((entry.total - entry.completed) / totalAll) * 100; + if (remW > 0) { segs.push({ left, w: remW, bg: `color-mix(in srgb, ${color} 25%, transparent)`, courseId: entry.courseId }); left += remW; } // remaining: faded + }); + + while (bar.children.length > segs.length) bar.lastChild.remove(); + + segs.forEach((seg, idx) => { + let el = bar.children[idx]; + if (!el) { + el = document.createElement('div'); + el.className = 'cr-ol-seg'; + el.style.cssText = 'position:absolute;top:0;height:100%;transition:left .6s ease,width .6s ease,background .3s ease,opacity .3s ease;'; + bar.appendChild(el); + } + el.style.left = `${seg.left}%`; + el.style.width = `${seg.w}%`; + el.style.background = seg.bg; + el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : ''; + attachProgressFilterClick(el, seg.courseId); + el.onmouseenter = () => { el.style.opacity = '0.8'; }; + el.onmouseleave = () => { el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : ''; }; + }); +} + +function renderProgressRings(container, scopedData) { + const mode = getProgressRingMode(); + if (mode === "none") { container.innerHTML = ""; return; } + + const allAssignments = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note")); + + const groups = {}; + allAssignments.forEach(item => { + const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal"); + groups[cid] = groups[cid] || []; + groups[cid].push(item); + }); + + 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; + return { courseId: cid, total: arr.length, completed }; + }).filter(e => e.total > 0); + + if (!entries.length) { container.innerHTML = ""; return; } + + // sort by total desc and limit to 6 courses + entries.sort((a, b) => b.total - a.total); + const shown = entries.slice(0, 6); + + const totalAll = shown.reduce((s, e) => s + e.total, 0); + const completedAll = shown.reduce((s, e) => s + e.completed, 0); + const percent = totalAll === 0 ? 0 : Math.round((completedAll / totalAll) * 100); + + // wrapper reused across renders; clear on mode switch so each mode rebuilds fresh DOM + let wrapper = container.querySelector('.canvasrefined-progress-wrapper'); + if (!wrapper) { + wrapper = document.createElement('div'); + wrapper.className = 'canvasrefined-progress-wrapper'; + wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;position:relative;width:100%;box-sizing:border-box;'; + container.appendChild(wrapper); + } + if (wrapper.dataset.mode !== mode) { + wrapper.innerHTML = ''; + wrapper.dataset.mode = mode; + } + + if (mode === "rings") { + renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent); + } else if (mode === "rainbow") { + renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent); + } else if (mode === "lines") { + renderProgressLines(wrapper, shown); + } else if (mode === "line") { + renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent); + } +} + function buildPlannerNotePayload(form) { const title = form.querySelector("#better-todo-new-task-title")?.value?.trim(); const details = form.querySelector("#better-todo-new-task-details")?.value?.trim(); @@ -2149,9 +2448,20 @@ async function createTodoSections(location) { }) : data; - announcements = scopedData.filter(item => item.plannable_type == "announcement"); - assignmentsDue = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && !item.submissions?.submitted && !item.planner_override?.marked_complete); - completed = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete)); + // Clicking a color in the progress display filters the list to that + // one class. The filter only makes sense where multiple classes show + // (dashboard/profile), so it is cleared on course pages. + if (courseId) betterTodoProgressFilter = null; + const displayData = (betterTodoProgressFilter == null) + ? scopedData + : scopedData.filter(item => { + const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal"); + return cid === String(betterTodoProgressFilter); + }); + + announcements = displayData.filter(item => item.plannable_type == "announcement"); + assignmentsDue = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && !item.submissions?.submitted && !item.planner_override?.marked_complete); + completed = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete)); // console.log("assignments", assignmentsDue); // console.log("announcements", announcements); // console.log("completed", completed); @@ -2217,7 +2527,7 @@ async function createTodoSections(location) { // populate progress rings placeholder (respect user toggle) const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); if (progressPlaceholder) { - if (options.todo_progress_rings === undefined || options.todo_progress_rings === true) { + if (progressRingsEnabled()) { renderProgressRings(progressPlaceholder, scopedData); } else { progressPlaceholder.innerHTML = ""; @@ -2648,7 +2958,7 @@ function markAs(item, element) { // update progress rings immediately so they animate while the item slides/fades const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); - if (progressPlaceholder && typeof assignments?.then === 'function' && (options.todo_progress_rings === undefined || options.todo_progress_rings === true)) { + if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) { assignments.then(data => { const courseId = getCurrentCourseId(); const scopedData = courseId diff --git a/js/popup.js b/js/popup.js index fb6a032..9538846 100644 --- a/js/popup.js +++ b/js/popup.js @@ -135,7 +135,7 @@ const defaultOptions = { "tab_icons": false, "todo_hide_feedback": false, "todo_full_height": false, - "todo_progress_rings": true, + "todo_progress_rings": "rings", "todo_confetti": true, "device_dark": false, "cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 }, @@ -255,6 +255,22 @@ function setupSidebarScaleSlider(initial) { }); } +// Progress display dropdown. Normalizes legacy boolean values: +// true -> rings, false/undefined -> none. +function setupProgressRingsSelect(initial) { + const el = document.querySelector("#todo_progress_rings"); + if (!el) return; + let value = initial; + if (value === true) value = "rings"; + else if (value === false || value === undefined || value === null) value = "none"; + const allowed = ["none", "rings", "rainbow", "lines", "line"]; + if (!allowed.includes(value)) value = "rings"; + el.value = value; + el.addEventListener("change", function () { + chrome.storage.sync.set({ "todo_progress_rings": this.value }); + }); +} + function setupAutoDarkInput(initial, time) { let el = document.querySelector('#' + time); el.value = initial.hour + ":" + initial.minute; @@ -753,7 +769,6 @@ function setup() { "gpa_calc_cumulative", // /*'card_method_date',*/ "show_updates", "todo_hide_feedback", - "todo_progress_rings", "todo_confetti", "todo_full_height", "device_dark", @@ -865,6 +880,10 @@ function setup() { identifier: "customBackgroundScale", setup: (initial) => setupCustomBackgroundScale(initial), }, + { + identifier: "todo_progress_rings", + setup: (initial) => setupProgressRingsSelect(initial), + }, ], };