Project 1: Tasky, the To-Do List

Senior Year, page 3 of 7. A seven-minute read.

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:

JavaScript
[
  { id: 1, text: "Buy plantain and eggs", done: false },
  { id: 2, text: "Call Grandma", done: true },
]

The HTML

index.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

style.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

script.js (part 1 of 5)
// ---------- 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

script.js (part 2 of 5)
// ---------- 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

script.js (part 3 of 5)
// ---------- 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 find to get the task by its id, and flips its done value with !.
  • Deleting uses filter to keep every task except the one with that id.
  • Clearing done uses filter to keep only the tasks that aren't done.

Part 4: Drawing the page

script.js (part 4 of 5)
// ---------- 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, using createElement (Grade 11). The task's id is stored in a data-id attribute, so our click handler can find out which task was clicked. The text uses textContent, never innerHTML, 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 of toggle: 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

script.js (part 5 of 5)
// ---------- 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 from data-id. (It's a string in the HTML, so we convert it with Number.)
  • Each filter button sets currentFilter and calls render(). (We don't call update() 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! 🎉

Try it yourselfLevel up Tasky
  1. 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.)
  2. Due dates: add a date input to the form, store a dueDate on each task, and show overdue tasks in red.
  3. Priorities: let the user mark tasks as high, medium or low priority, and sort the list by priority.
  4. Keyboard shortcut: press / anywhere on the page to jump to the input box.
  5. Clear all: add a "Clear all" button, but ask for confirmation first with confirm().

Reading is step one

CurlyScript turns these notes into a game: short questions, an explanation after every answer, and a test at the end of each grade. Free, with nothing to sign up for.

Start learning