The plan: A to-do list app where you can add tasks, tick them off, delete them, and filter them. It should remember your tasks even after you close the browser.
Features (MVP):
- Type a task and press Enter (or click Add) to add it
- Tick a checkbox to mark a task as done (and untick it to undo)
- Click ✕ to delete a task
- Filter the list: All, To do, or Done
- See how many tasks are left
- Clear done removes all completed tasks
- Everything is saved in
localStorage
The data: an array of task objects, each with an id (so we can tell tasks apart, even if two have the same text), the text, and whether it's done:
[
{ id: 1, text: "Buy plantain and eggs", done: false },
{ id: 2, text: "Call Grandma", done: true },
]
The HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tasky - My To-Do List</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app">
<h1>📝 Tasky</h1>
<form id="todo-form">
<input id="todo-input" type="text" placeholder="What needs doing?" autocomplete="off" />
<button type="submit">Add</button>
</form>
<div class="filters">
<button class="filter active" data-filter="all">All</button>
<button class="filter" data-filter="active">To do</button>
<button class="filter" data-filter="done">Done</button>
</div>
<ul id="todo-list"></ul>
<footer>
<span id="counter"></span>
<button id="clear-done">Clear done</button>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>
Everything has a sensible id or class so our JavaScript can find it. The three filter buttons each carry a data-filter attribute (Grade 11), which tells our code which filter each button represents. The <ul> is empty: JavaScript will fill it.
The CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, sans-serif;
background: linear-gradient(160deg, #fff6b8, #f7df1e);
display: flex;
justify-content: center;
padding: 40px 16px;
}
.app {
width: 100%;
max-width: 480px;
background: white;
border-radius: 20px;
padding: 24px;
box-shadow: 0 10px 30px rgba(34, 48, 74, 0.2);
align-self: flex-start;
}
h1 { margin: 0 0 16px; color: #22304a; }
form { display: flex; gap: 8px; }
input[type="text"] {
flex: 1;
padding: 12px;
font-size: 16px;
border: 2px solid #d5d9e2;
border-radius: 12px;
}
button {
font-size: 15px;
padding: 10px 16px;
border: none;
border-radius: 12px;
background: #22304a;
color: white;
cursor: pointer;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
.filters { display: flex; gap: 6px; margin: 16px 0 8px; }
.filter { background: #eef1f6; color: #22304a; padding: 6px 12px; }
.filter.active { background: #2ec4b6; color: white; }
ul { list-style: none; padding: 0; margin: 0; }
.todo {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #eef1f6;
}
.todo .text { flex: 1; font-size: 17px; }
.todo.done .text { text-decoration: line-through; color: #9aa3b2; }
.toggle { width: 20px; height: 20px; cursor: pointer; }
.delete { background: transparent; color: #ff6b6b; font-size: 18px; padding: 4px 8px; }
.delete:hover { background: #fff1f1; }
.empty { color: #9aa3b2; text-align: center; padding: 20px 0; }
footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
color: #6b7280;
}
The important bits for our JavaScript are the classes it will switch on and off: .done (strikes through a finished task), .filter.active (highlights the selected filter) and :disabled (greys out a button that can't be used right now).
The JavaScript
We'll go through script.js in five parts. Type each part into the same file, one after another.
Part 1: The state and the elements
// ---------- State: the single source of truth ----------
const STORAGE_KEY = "tasky-todos";
let todos = loadTodos();
let currentFilter = "all";
// Give every new task a unique id: one more than the biggest id so far
let nextId = todos.length > 0 ? Math.max(...todos.map((t) => t.id)) + 1 : 1;
// ---------- Elements we need ----------
const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const list = document.querySelector("#todo-list");
const counter = document.querySelector("#counter");
const clearDoneButton = document.querySelector("#clear-done");
const filterButtons = document.querySelectorAll(".filter");
At the top, we set up the state: the todos array (loaded from storage) and which filter is selected. Then we grab every element we'll need, once, and store them in variables so we don't have to search the page over and over.
The nextId line uses a trick from Grade 13: todos.map((t) => t.id) makes an array of all the ids, and Math.max(...ids) spreads them into Math.max to find the biggest. New tasks get an id one higher than that, so ids never clash, even after a reload.
(Did you notice we call loadTodos() before we've written it? That's fine, because it's a function declaration, and function declarations are hoisted. Remember Grade 9?)
Part 2: Saving and loading
// ---------- Saving and loading ----------
function loadTodos() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
} catch {
return []; // the saved data was broken, so start fresh
}
}
function saveTodos() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
Exactly the pattern from Grade 12. localStorage only stores strings, so we use JSON.stringify to save and JSON.parse to load. On the very first visit there's nothing saved, so getItem returns null, and JSON.parse(null) is also null, so the ?? (Grade 13) falls back to an empty array. And just in case the saved data ever gets corrupted, the try...catch (Summer School) stops the whole app from crashing.
Part 3: Changing the state
// ---------- Changing the state ----------
function addTodo(text) {
todos.push({ id: nextId, text, done: false });
nextId++;
update();
}
function toggleTodo(id) {
const todo = todos.find((t) => t.id === id);
if (todo) {
todo.done = !todo.done;
}
update();
}
function deleteTodo(id) {
todos = todos.filter((t) => t.id !== id);
update();
}
function clearDone() {
todos = todos.filter((t) => !t.done);
update();
}
function update() {
saveTodos();
render();
}
These functions are the only places where the state changes. Each one changes todos and then calls update(), which saves and redraws. Notice how simple each one is, thanks to the array methods from Grade 10:
- Toggling uses
findto get the task by its id, and flips itsdonevalue with!. - Deleting uses
filterto keep every task except the one with that id. - Clearing done uses
filterto keep only the tasks that aren't done.
Part 4: Drawing the page
// ---------- Drawing the page from the state ----------
function getVisibleTodos() {
if (currentFilter === "active") return todos.filter((t) => !t.done);
if (currentFilter === "done") return todos.filter((t) => t.done);
return todos;
}
function createTodoElement(todo) {
const li = document.createElement("li");
li.className = "todo";
li.dataset.id = todo.id;
if (todo.done) {
li.classList.add("done");
}
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "toggle";
checkbox.checked = todo.done;
const text = document.createElement("span");
text.className = "text";
text.textContent = todo.text;
const deleteButton = document.createElement("button");
deleteButton.className = "delete";
deleteButton.textContent = "✕";
deleteButton.setAttribute("aria-label", `Delete ${todo.text}`);
li.append(checkbox, text, deleteButton);
return li;
}
function render() {
list.innerHTML = "";
const visible = getVisibleTodos();
if (visible.length === 0) {
const empty = document.createElement("li");
empty.className = "empty";
empty.textContent = todos.length === 0 ? "Nothing to do. Enjoy your day! 🌴" : "Nothing here.";
list.append(empty);
}
visible.forEach((todo) => list.append(createTodoElement(todo)));
const left = todos.filter((t) => !t.done).length;
counter.textContent = `${left} ${left === 1 ? "task" : "tasks"} left`;
clearDoneButton.disabled = !todos.some((t) => t.done);
filterButtons.forEach((button) => {
button.classList.toggle("active", button.dataset.filter === currentFilter);
});
}
This is the render half of the pattern. render() wipes the list and rebuilds it entirely from the state:
getVisibleTodos()decides which tasks to show, based on the current filter.- If there's nothing to show, we show a friendly message instead of a blank space.
createTodoElement()builds one<li>for one task, usingcreateElement(Grade 11). The task's id is stored in adata-idattribute, so our click handler can find out which task was clicked. The text usestextContent, neverinnerHTML, so even if someone types<script>as a task, it's shown safely as plain text.- Finally, it updates the counter, disables the Clear done button when there's nothing to clear, and highlights the active filter.
classList.toggle("active", condition)is a handy form oftoggle: with a second argument, it adds the class if the condition is true and removes it if it's false.
Part 5: Listening to the user
// ---------- Listening to the user ----------
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
addTodo(text);
input.value = "";
input.focus();
});
// One listener for the whole list (event delegation!)
list.addEventListener("click", (event) => {
const li = event.target.closest(".todo");
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains("toggle")) toggleTodo(id);
if (event.target.classList.contains("delete")) deleteTodo(id);
});
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
currentFilter = button.dataset.filter;
render();
});
});
clearDoneButton.addEventListener("click", clearDone);
// ---------- Start the app ----------
render();
- The form's submit event handles both clicking Add and pressing Enter. We call
preventDefault()so the page doesn't reload,trim()the text, and ignore empty input. - The list uses event delegation (Grade 12): one listener on the whole
<ul>handles every checkbox and every delete button, including ones for tasks that don't exist yet.closest(".todo")climbs from whatever was clicked up to its task's<li>, and we read the id fromdata-id. (It's a string in the HTML, so we convert it withNumber.) - Each filter button sets
currentFilterand callsrender(). (We don't callupdate()here, because the filter is just a view setting; there's nothing new to save.) - And finally,
render()draws the page for the first time.
Open the app, add some tasks, tick some off, refresh the page... and they're all still there. You built a real app! 🎉
- Edit tasks: double-click a task's text to edit it. (Hint: listen for
dblclick, swap the<span>for an<input>, and save when the user presses Enter.) - Due dates: add a date input to the form, store a
dueDateon each task, and show overdue tasks in red. - Priorities: let the user mark tasks as high, medium or low priority, and sort the list by priority.
- Keyboard shortcut: press / anywhere on the page to jump to the input box.
- Clear all: add a "Clear all" button, but ask for confirmation first with
confirm().