From 7631901f502bc360a99bd952a92e37d0db6f4fa1 Mon Sep 17 00:00:00 2001 From: Jorel97 Date: Sat, 30 May 2026 18:57:25 -0600 Subject: [PATCH] Improve task list rendering performance --- README.md | 2 + index.html | 1 + src/app.js | 119 ++++++++++++++++++++++++----------- test/app-performance.test.js | 17 +++++ 4 files changed, 103 insertions(+), 36 deletions(-) create mode 100644 test/app-performance.test.js diff --git a/README.md b/README.md index f1eba62..151da2c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o - Single-page, zero-build web app - Tasks persist in `localStorage` by default, with optional GitHub sign-in sync +- Task list updates are incremental, so toggling or deleting one task does not + rebuild the whole list - GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie - Keyboard-friendly (more shortcuts coming, see #5) - Light & dark themes (dark coming, see #1) diff --git a/index.html b/index.html index b6b00fb..1cae420 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ TaskForge — open-source task tracker + diff --git a/src/app.js b/src/app.js index 46dd724..36df88d 100644 --- a/src/app.js +++ b/src/app.js @@ -18,6 +18,7 @@ const authMessage = document.getElementById("auth-message"); let tasks = []; let user = null; +const taskNodes = new Map(); async function init() { const localTasks = loadLocal(); @@ -41,59 +42,59 @@ async function init() { await save(tasks, user); } renderAuth(); - render(); + renderTasks(); } -function render() { - list.innerHTML = ""; +function renderTasks() { + list.textContent = ""; + taskNodes.clear(); if (tasks.length === 0) { emptyState.hidden = false; return; } + emptyState.hidden = true; + const fragment = document.createDocumentFragment(); for (const task of tasks) { - const li = document.createElement("li"); - li.className = "task-item" + (task.done ? " done" : ""); - li.dataset.id = task.id; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = task.done; - checkbox.addEventListener("change", async () => { - tasks = toggleTask(tasks, task.id); - await save(tasks, user); - render(); - }); - - const label = document.createElement("span"); - label.className = "task-title"; - label.textContent = task.title; - - const del = document.createElement("button"); - del.type = "button"; - del.className = "task-delete"; - del.textContent = "✕"; - del.setAttribute("aria-label", `Delete task: ${task.title}`); - del.addEventListener("click", async () => { - tasks = removeTask(tasks, task.id); - await save(tasks, user); - render(); - }); - - li.append(checkbox, label, del); - list.appendChild(li); + const node = createTaskNode(task); + taskNodes.set(task.id, node); + fragment.appendChild(node); } + list.appendChild(fragment); } form.addEventListener("submit", async (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + const task = createTask(title); + tasks = [task, ...tasks]; await save(tasks, user); input.value = ""; input.focus(); - render(); + prependTask(task); +}); + +list.addEventListener("change", async (event) => { + if (!event.target.matches("[data-task-toggle]")) return; + const taskId = event.target.closest(".task-item")?.dataset.id; + if (!taskId) return; + + tasks = toggleTask(tasks, taskId); + const task = tasks.find((item) => item.id === taskId); + updateTaskNode(task); + await save(tasks, user); +}); + +list.addEventListener("click", async (event) => { + const deleteButton = event.target.closest("[data-task-delete]"); + if (!deleteButton) return; + const taskId = deleteButton.closest(".task-item")?.dataset.id; + if (!taskId) return; + + tasks = removeTask(tasks, taskId); + removeTaskNode(taskId); + await save(tasks, user); }); authAction.addEventListener("click", async () => { @@ -103,7 +104,7 @@ authAction.addEventListener("click", async () => { await logout(); user = null; tasks = await load(user); - render(); + renderTasks(); renderAuth(); return; } @@ -138,6 +139,52 @@ function setAuthMessage(message) { authMessage.hidden = !message; } +function createTaskNode(task) { + const li = document.createElement("li"); + li.className = "task-item" + (task.done ? " done" : ""); + li.dataset.id = task.id; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = task.done; + checkbox.dataset.taskToggle = ""; + + const label = document.createElement("span"); + label.className = "task-title"; + label.textContent = task.title; + + const del = document.createElement("button"); + del.type = "button"; + del.className = "task-delete"; + del.textContent = "x"; + del.dataset.taskDelete = ""; + del.setAttribute("aria-label", `Delete task: ${task.title}`); + + li.append(checkbox, label, del); + return li; +} + +function prependTask(task) { + const node = createTaskNode(task); + taskNodes.set(task.id, node); + list.prepend(node); + emptyState.hidden = true; +} + +function updateTaskNode(task) { + const node = taskNodes.get(task.id); + if (!node) return; + node.classList.toggle("done", task.done); + node.querySelector("[data-task-toggle]").checked = task.done; +} + +function removeTaskNode(taskId) { + const node = taskNodes.get(taskId); + if (node) node.remove(); + taskNodes.delete(taskId); + emptyState.hidden = tasks.length > 0; +} + function mergeTasks(localTasks, remoteTasks) { const byId = new Map(); for (const task of [...remoteTasks, ...localTasks]) { diff --git a/test/app-performance.test.js b/test/app-performance.test.js new file mode 100644 index 0000000..3724b72 --- /dev/null +++ b/test/app-performance.test.js @@ -0,0 +1,17 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const test = require("node:test"); + +test("task list rendering avoids full-list rebuilds for item actions", async () => { + const appSource = await fs.readFile( + path.join(__dirname, "..", "src", "app.js"), + "utf8", + ); + + assert.equal(appSource.includes("list.innerHTML"), false); + assert.match(appSource, /list\.addEventListener\("change"/); + assert.match(appSource, /list\.addEventListener\("click"/); + assert.match(appSource, /updateTaskNode\(task\)/); + assert.match(appSource, /removeTaskNode\(taskId\)/); +});