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\)/);
+});