diff --git a/js/content.js b/js/content.js index 3deeeaa..aad33c3 100644 --- a/js/content.js +++ b/js/content.js @@ -1243,6 +1243,211 @@ function formatTimeForInput(date) { return `${hours}:${minutes}`; } +function renderProgressRings(container, scopedData) { + const allAssignments = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note")); + + // 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); + }); + + 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 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 + 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)); + + // 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('.bettercanvas-progress-wrapper'); + if (!wrapper) { + wrapper = document.createElement('div'); + wrapper.className = 'bettercanvas-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.bettercanvas-progress-svg'); + if (!svg) { + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'bettercanvas-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}`); + } + + // ensure overlay text exists + let overlay = wrapper.querySelector('.bettercanvas-progress-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'bettercanvas-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'; + const textWrap = document.createElement('div'); + textWrap.style.textAlign = 'center'; + textWrap.style.color = 'var(--bctext-0)'; + textWrap.innerHTML = `
${percent}%
${completedAll}/${totalAll} done
`; + overlay.appendChild(textWrap); + wrapper.appendChild(overlay); + } else { + const pc = overlay.querySelector('.bettercanvas-progress-percent'); + const cnt = overlay.querySelector('.bettercanvas-progress-count'); + if (pc) pc.textContent = `${percent}%`; + if (cnt) cnt.textContent = `${completedAll}/${totalAll} done`; + } + + // Update or create rings in-place + const existingBg = svg.querySelectorAll('circle.bettercanvas-ring-bg'); + const existingFg = svg.querySelectorAll('circle.bettercanvas-progress-ring'); + + // reuse or create circles per shown entry + shown.forEach((entry, idx) => { + const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor)); + 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 circle + let bg = svg.querySelector(`circle.bettercanvas-ring-bg[data-idx='${idx}']`); + if (!bg) { + bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bg.classList.add('bettercanvas-ring-bg'); + bg.setAttribute('data-idx', String(idx)); + svg.appendChild(bg); + } + bg.setAttribute('cx', String(cx)); + bg.setAttribute('cy', String(cy)); + bg.setAttribute('r', String(radius)); + bg.setAttribute('stroke', color); + bg.setAttribute('stroke-opacity', '0.25'); + bg.setAttribute('stroke-width', String(stroke)); + bg.setAttribute('fill', 'none'); + + // foreground (progress) circle + let fg = svg.querySelector(`circle.bettercanvas-progress-ring[data-idx='${idx}']`); + const dasharrayVal = circumference.toFixed(3); + const dashoffsetTarget = (circumference * (1 - prog)).toFixed(3); + if (!fg) { + fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + fg.classList.add('bettercanvas-progress-ring'); + fg.setAttribute('data-idx', String(idx)); + fg.setAttribute('stroke-linecap', 'round'); + fg.setAttribute('transform', `rotate(-90 ${cx} ${cy})`); + fg.setAttribute('stroke-dasharray', dasharrayVal); + fg.setAttribute('stroke-dashoffset', dasharrayVal); // start empty + fg.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease'; + svg.appendChild(fg); + } + fg.setAttribute('cx', String(cx)); + fg.setAttribute('cy', String(cy)); + fg.setAttribute('r', String(radius)); + fg.setAttribute('stroke', color); + 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); + }); + }); + }); + + // remove any extra existing circles + const maxIdx = shown.length - 1; + svg.querySelectorAll('circle.bettercanvas-ring-bg, circle.bettercanvas-progress-ring').forEach(c => { + const idx = parseInt(c.getAttribute('data-idx')); + if (Number.isNaN(idx) || idx > maxIdx) c.remove(); + }); +} + 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(); @@ -1471,10 +1676,13 @@ async function createTodoSections(location) { let today = new Date(); today.setHours(0,0,0,0); const todayString = today.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }); - header.innerHTML = ` -

Tasks

-

${todayString}

- `; + header.innerHTML = ` +

Tasks

+

${todayString}

+ `; + + // placeholder for progress rings above the tab/filter control + makeElement("div", location, { id: "better-todo-progress-placeholder", style: "display:flex;justify-content:center;margin-top:8px;" }); let filterControl = makeElement("div", location, { "id": "better-todo-filter" }); filterControl.innerHTML = ` @@ -1601,9 +1809,9 @@ async function createTodoSections(location) { } - if (betterTodoFilter == "tasks") { - populateAssignments(); - } + if (betterTodoFilter == "tasks") { + populateAssignments(); + } if (betterTodoFilter == "announcements") { populateAnnouncements(); } @@ -1612,8 +1820,23 @@ async function createTodoSections(location) { } const feedbackElement = location.querySelector(".recent_feedback"); - ensureTodoTaskMenu(location, feedbackElement); - if (feedbackElement) { + + // populate progress rings placeholder + const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); + if (progressPlaceholder) { + renderProgressRings(progressPlaceholder, scopedData); + } + + // Only show the Add Task control on the Assignments (tasks) tab. + if (betterTodoFilter === "tasks") { + ensureTodoTaskMenu(location, feedbackElement); + } else { + // remove the actions row if it exists when not on the assignments tab + const existing = location.querySelector("#better-todo-actions-row"); + if (existing) existing.remove(); + } + + if (feedbackElement) { if (options.todo_hide_feedback == true) { feedbackElement.style.display = "none"; } else { @@ -1893,10 +2116,37 @@ function markAs(item, element) { item.planner_override.marked_complete = completeState; element.style.transform = "translate(100%)"; element.style.opacity = "0"; - setTimeout(() => { - clearTodoList(); - createTodoSections(document.querySelector("#bettercanvas-todo-list")); - }, 400); + + // 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') { + assignments.then(data => { + const courseId = getCurrentCourseId(); + const scopedData = courseId + ? data.map(d => Object.assign({}, d)) // shallow copy + .filter(d => { + const itemCourseId = parseInt(d.course_id || d.context_id || d?.plannable?.course_id); + return itemCourseId === courseId; + }) + : data.map(d => Object.assign({}, d)); + + // reflect the updated state for this item in the snapshot + for (let i = 0; i < scopedData.length; i++) { + if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) { + scopedData[i].planner_override = scopedData[i].planner_override || {}; + scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete; + break; + } + } + + renderProgressRings(progressPlaceholder, scopedData); + }); + } + + setTimeout(() => { + clearTodoList(); + createTodoSections(document.querySelector("#bettercanvas-todo-list")); + }, 400); } }) .catch(err => console.error("error marking as complete", err));