Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskForge — open-source task tracker</title>
<link rel="preconnect" href="https://avatars.githubusercontent.com" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
Expand Down
119 changes: 83 additions & 36 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand All @@ -103,7 +104,7 @@ authAction.addEventListener("click", async () => {
await logout();
user = null;
tasks = await load(user);
render();
renderTasks();
renderAuth();
return;
}
Expand Down Expand Up @@ -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]) {
Expand Down
17 changes: 17 additions & 0 deletions test/app-performance.test.js
Original file line number Diff line number Diff line change
@@ -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\)/);
});