Home Module 15 Todo App

Project Overview

The classic to-do app — but built properly with a data-first architecture. All state lives in an array; the DOM is just a rendered view of that array. This is the pattern used in React, Vue, and every modern framework.

What you will build

  • Add todos with text and priority (high/medium/low)
  • Mark todos complete (toggle)
  • Delete individual todos
  • Filter: All / Active / Completed
  • Clear all completed button
  • Item count display
  • localStorage persistence — survives refresh

Concepts used

  • Array as source of truth
  • Render function that rebuilds DOM from data
  • Event delegation
  • localStorage + JSON
  • Array filter for active view

Architecture

// Data layer — pure functions, no DOM
function loadTodos()          { ... } // read from localStorage
function saveTodos(todos)     { ... } // write to localStorage
function addTodo(text, prio)  { ... } // returns new todos array
function toggleTodo(id)       { ... } // returns new todos array
function deleteTodo(id)       { ... } // returns new todos array
function clearCompleted()     { ... } // returns new todos array

// View layer
function renderTodos(todos, filter) { ... } // builds DOM from data

// Controller — glue
function handleAction(action, payload) {
  // update data → save → re-render
}

Complete Working Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo App</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, sans-serif; background: #f1f5f9; min-height: 100vh;
           display: flex; justify-content: center; padding: 2rem 1rem; }
    .app { width: 100%; max-width: 540px; }
    h1 { text-align: center; font-size: 2.5rem; color: #1e293b; margin-bottom: 1.5rem; }
    .add-form { display: flex; gap: .5rem; margin-bottom: 1rem; }
    .add-form input { flex: 1; padding: .75rem 1rem; border: 2px solid #e2e8f0;
                      border-radius: .5rem; font-size: 1rem; outline: none; }
    .add-form input:focus { border-color: #3b82f6; }
    .add-form select { padding: .75rem; border: 2px solid #e2e8f0; border-radius: .5rem;
                       font-size: .9rem; outline: none; }
    .add-form button { padding: .75rem 1.25rem; background: #3b82f6; color: #fff;
                       border: none; border-radius: .5rem; font-size: 1rem; cursor: pointer; }
    .add-form button:hover { background: #2563eb; }
    .filters { display: flex; gap: .5rem; margin-bottom: 1rem; }
    .filters button { flex: 1; padding: .5rem; border: 2px solid #e2e8f0; background: #fff;
                      border-radius: .5rem; cursor: pointer; font-size: .85rem; }
    .filters button.active { border-color: #3b82f6; background: #eff6ff; color: #1d4ed8; font-weight: 700; }
    .todo-list { background: #fff; border-radius: .75rem; overflow: hidden;
                 box-shadow: 0 1px 4px #0001; }
    .todo-item { display: flex; align-items: center; gap: .75rem; padding: 1rem 1.25rem;
                 border-bottom: 1px solid #f1f5f9; transition: background .15s; }
    .todo-item:last-child { border-bottom: none; }
    .todo-item:hover { background: #f8fafc; }
    .todo-item.done .todo-text { text-decoration: line-through; color: #94a3b8; }
    .check { width: 20px; height: 20px; border: 2px solid #cbd5e1; border-radius: 50%;
             cursor: pointer; flex-shrink: 0; display: flex; align-items: center;
             justify-content: center; transition: background .15s, border-color .15s; }
    .check.checked { background: #22c55e; border-color: #22c55e; color: #fff; }
    .todo-text { flex: 1; font-size: .95rem; color: #1e293b; }
    .prio { font-size: .7rem; font-weight: 700; padding: .15rem .5rem; border-radius: 999px; flex-shrink: 0; }
    .prio-high   { background: #fee2e2; color: #b91c1c; }
    .prio-medium { background: #fef3c7; color: #92400e; }
    .prio-low    { background: #dcfce7; color: #166534; }
    .del-btn { background: none; border: none; color: #ef4444; cursor: pointer;
               font-size: 1.1rem; padding: .25rem; opacity: 0; transition: opacity .15s; }
    .todo-item:hover .del-btn { opacity: 1; }
    .footer-row { display: flex; justify-content: space-between; align-items: center;
                  padding: .75rem 1.25rem; font-size: .82rem; color: #64748b; }
    .clear-btn { background: none; border: none; color: #ef4444; cursor: pointer;
                 font-size: .82rem; }
    .empty { text-align: center; padding: 2rem; color: #94a3b8; font-size: .95rem; }
  </style>
</head>
<body>
  <div class="app">
    <h1>todos</h1>
    <form class="add-form" id="add-form">
      <input id="todo-input" placeholder="What needs to be done?" autocomplete="off">
      <select id="prio-select">
        <option value="medium">Medium</option>
        <option value="high">High</option>
        <option value="low">Low</option>
      </select>
      <button type="submit">Add</button>
    </form>
    <div class="filters" id="filters">
      <button class="active" data-filter="all">All</button>
      <button data-filter="active">Active</button>
      <button data-filter="done">Completed</button>
    </div>
    <div class="todo-list" id="list"></div>
  </div>

  <script>
    const STORAGE_KEY = 'todos_v1';
    let filter = 'all';
    let todos  = load();

    function load() {
      try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; }
      catch { return []; }
    }
    function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }

    function render() {
      const list = document.querySelector('#list');
      const visible = todos.filter(t =>
        filter === 'all'    ? true :
        filter === 'active' ? !t.done :
                               t.done
      );

      list.innerHTML = '';

      if (!visible.length) {
        list.innerHTML = '<p class="empty">Nothing here yet!</p>';
      } else {
        visible.forEach(todo => {
          const item = document.createElement('div');
          item.className = `todo-item${todo.done ? ' done' : ''}`;
          item.dataset.id = todo.id;

          const check = document.createElement('div');
          check.className = `check${todo.done ? ' checked' : ''}`;
          check.dataset.action = 'toggle';
          check.textContent = todo.done ? '✓' : '';

          const text = document.createElement('span');
          text.className = 'todo-text';
          text.textContent = todo.text; // textContent — safe

          const prio = document.createElement('span');
          prio.className = `prio prio-${todo.priority}`;
          prio.textContent = todo.priority;

          const del = document.createElement('button');
          del.className = 'del-btn';
          del.dataset.action = 'delete';
          del.textContent = '✕';

          item.append(check, text, prio, del);
          list.appendChild(item);
        });
      }

      const active = todos.filter(t => !t.done).length;
      const doneCount = todos.filter(t => t.done).length;

      // Footer
      let footer = list.querySelector('.footer-row');
      if (!footer) {
        footer = document.createElement('div');
        footer.className = 'footer-row';
      }
      footer.innerHTML = '';
      const count = document.createElement('span');
      count.textContent = `${active} item${active !== 1 ? 's' : ''} left`;
      footer.appendChild(count);
      if (doneCount) {
        const clearBtn = document.createElement('button');
        clearBtn.className = 'clear-btn';
        clearBtn.dataset.action = 'clear-done';
        clearBtn.textContent = `Clear completed (${doneCount})`;
        footer.appendChild(clearBtn);
      }
      list.appendChild(footer);
    }

    // Event delegation on list
    document.querySelector('#list').addEventListener('click', e => {
      const item = e.target.closest('[data-id]');
      const action = e.target.closest('[data-action]')?.dataset.action;
      if (!action) return;

      if (action === 'toggle' && item) {
        const id = item.dataset.id;
        todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
      } else if (action === 'delete' && item) {
        todos = todos.filter(t => t.id !== item.dataset.id);
      } else if (action === 'clear-done') {
        todos = todos.filter(t => !t.done);
      }
      save(); render();
    });

    // Add form
    document.querySelector('#add-form').addEventListener('submit', e => {
      e.preventDefault();
      const text = document.querySelector('#todo-input').value.trim();
      if (!text) return;
      const priority = document.querySelector('#prio-select').value;
      todos = [...todos, { id: Date.now().toString(), text, priority, done: false, createdAt: Date.now() }];
      document.querySelector('#todo-input').value = '';
      save(); render();
    });

    // Filters
    document.querySelector('#filters').addEventListener('click', e => {
      const btn = e.target.closest('[data-filter]');
      if (!btn) return;
      filter = btn.dataset.filter;
      document.querySelectorAll('#filters button').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      render();
    });

    render();
  </script>
</body>
</html>

Code Explained

Data-first architecture

The todos array is the only source of truth. Every action (add, toggle, delete) modifies the array and then calls save() and render(). The DOM is completely rebuilt on each render — simple, predictable, and correct.

Immutable updates

todos.map(t => t.id === id ? { ...t, done: !t.done } : t) creates a new array with a new object for the toggled item — the original objects are never mutated. This is the same pattern React uses for state updates.

textContent for user text

text.textContent = todo.text — user-entered text is always set via textContent, never innerHTML. This prevents XSS if a user enters <script> tags in a todo item.

Challenges

  1. Add inline editing: double-clicking a todo text makes it editable; Enter/blur saves, Escape cancels.
  2. Add drag-and-drop reordering using the HTML Drag and Drop API.
  3. Add due dates and highlight overdue items in red.
  4. Add categories/tags and filter by them.
  5. Add a search box that filters todos as you type (debounced).