Skip to editor content
learningjavascript.orglesson 19 of 25

Capstone Project: Task Manager

This is the last lesson of the core arc, and it is the one where everything meets. You will build a working in-browser Task Manager: a real form that adds tasks, a real list rendered from a real DOM, and real persistence so your tasks survive a page reload. No simulations — this is an app you can open in your browser and use.

What You'll Build

  • A Task model with validation and a completion state (objects and prototypes)
  • Persistence on real localStorage that survives a reload (from 20-local-storage-and-state)
  • A task-manager service that owns state and emits change events (closures, module pattern)
  • A real DOM renderer that builds <li> elements with document.createElement (from 10-dom-and-events)
  • A real form with validation that shows errors in the page (from 25-form-validation)

How this lesson runs

This lesson runs in two places, and the split is deliberate:

  • Logic steps run here. The Task model (Step 1) and the manager service (Step 3) are pure logic — no page, no browser APIs. They stay in the embedded playground, so you can run them and read their console output right on this page.
  • Browser steps run in your browser. The storage, DOM, and form steps use document, localStorage, and real events, which the in-page playground does not have. So Step 0 gives you a single index.html scaffold. You save it once, then grow it step by step. When a step says "save and open it in your browser," that is where the code lives.

Each step also names the earlier lesson it draws on. Error handling (09-error-handling) and async (08-async-and-promises) show up throughout — every storage read is wrapped in a try/catch, the way a real app guards against a corrupt or disabled store.

Step 0: The Scaffold

Create a file called index.html, paste this in, and open it in your browser. It is the shell the whole app grows inside — a form, an empty list, a stats line, and an empty error slot. The script element at the bottom is where every later step's code goes.

The ids and markup here are exact and intentional. Keep them as written — later steps (and the graded runner, described near the end) rely on them.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Task Manager</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
    #task-list { list-style: none; padding: 0; }
    #task-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
    #task-list li[data-status="completed"] .title { text-decoration: line-through; color: #888; }
    .title { flex: 1; }
    #form-error { color: #c00; min-height: 1.2em; margin: 0.25rem 0; }
    button { cursor: pointer; }
  </style>
</head>
<body>
  <h1>Task Manager</h1>

  <form id="new-task-form">
    <input id="new-task-title" type="text" placeholder="What needs doing?" autocomplete="off" />
    <button type="submit">Add</button>
  </form>
  <p id="form-error"></p>

  <span id="task-stats"></span>
  <ul id="task-list"></ul>

  <script>
    // Every step below adds code here, in order.
  </script>
</body>
</html>

Open it now. You should see the heading, an empty form, and nothing else. That empty page is the honest starting point — by the end of the lesson it fills itself from localStorage on load.

Step 1: The Task Model

Every app starts with its data model. The Task constructor validates its input and gives each task a stable id, a status, and a couple of helper methods on the prototype. This is pure logic, so run it right here:

function Task(title, id) {
  if (typeof title !== "string" || title.trim().length === 0) {
    throw new Error("Task title is required");
  }
  if (title.trim().length > 100) {
    throw new Error("Task title must be 100 characters or fewer");
  }

  this.id = id || Date.now() + Math.floor(Math.random() * 1000);
  this.title = title.trim();
  this.status = "pending";
  this.createdAt = new Date().toISOString();
}

Task.prototype.complete = function () {
  this.status = "completed";
};

Task.prototype.reopen = function () {
  this.status = "pending";
};

// toJSON controls what JSON.stringify writes — only plain data, no methods.
Task.prototype.toJSON = function () {
  return {
    id: this.id,
    title: this.title,
    status: this.status,
    createdAt: this.createdAt,
  };
};

// Try it
const task = new Task("Learn the DOM");
console.log(task.status);          // "pending"
task.complete();
console.log(task.status);          // "completed"

// Validation rejects bad input
try {
  new Task("");
} catch (error) {
  console.log("Rejected:", error.message);
}

console.log(JSON.stringify(task)); // plain data, no methods

Notice toJSON: when you serialize a Task, you get its data and nothing else. That matters in the next step, because serialization is exactly how tasks reach localStorage — and it is also why they come back as plain objects, not Task instances.

Capstone milestone

Milestone 1 — the Task model. You have a Task constructor that validates its title, tracks a pending/completed status, and serializes to plain data. This is the shape every later layer stores, renders, and mutates.

Hint: This is the same task-model milestone that lessons 03, 06, and 09 build toward. Here it becomes the real thing.

  • new Task('...') throws on an empty or over-long title
  • complete() and reopen() flip status between 'completed' and 'pending'
  • JSON.stringify(task) returns only id, title, status, createdAt — no methods

Step 2: Storage on Real localStorage

Now the app has to remember tasks between visits. In the console-simulation version this was a fake in-memory object with an artificial delay; here it is the real thinglocalStorage.setItem / getItem, and the data genuinely survives closing the tab.

Add this inside the script element of your index.html, below Step 1's Task:

// --- Storage ---------------------------------------------------------------
const STORAGE_KEY = "taskman.tasks.v1";

function saveTasks(tasks) {
  try {
    // Each task serializes via its toJSON to plain data.
    localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
  } catch (error) {
    // Quota exceeded, or storage disabled (private mode). Don't crash the app.
    console.warn("Could not save tasks:", error.message);
  }
}

function loadTasks() {
  let raw;
  try {
    raw = localStorage.getItem(STORAGE_KEY);
  } catch (error) {
    // Storage disabled entirely — start clean.
    console.warn("Could not read tasks:", error.message);
    return [];
  }

  if (!raw) return [];

  try {
    const plain = JSON.parse(raw);
    if (!Array.isArray(plain)) return [];
    // Rehydrate: JSON gives plain objects, so rebuild real Task instances
    // to get their methods (complete/reopen) back.
    return plain.map((data) => {
      const task = new Task(data.title, data.id);
      task.status = data.status === "completed" ? "completed" : "pending";
      task.createdAt = data.createdAt || task.createdAt;
      return task;
    });
  } catch (error) {
    // Corrupt JSON — throw the bad data away rather than crash on every load.
    console.warn("Stored tasks were corrupt, starting clean:", error.message);
    return [];
  }
}

Three things worth naming:

  • Serialization drops prototypes. JSON.stringify writes data; JSON.parse gives you back plain objects with no methods. Calling .complete() on a parsed object would throw. That is why loadTasks rehydrates — it feeds the stored data back through new Task(...) so the methods return.
  • Every access is guarded. Reading, parsing, and writing can each fail — a full quota, storage disabled in private mode, or JSON that got corrupted. A real app catches all three and degrades to "start clean" instead of showing a blank white screen.
  • This actually persists. Unlike the old fake-delay store, reloading the page and calling loadTasks() returns what you saved. You will see that at the end of the lesson.

Step 3: The Task Manager Service

The service is the core: it owns the task list, exposes operations, and emits an event whenever anything changes so the UI can re-render without the service knowing anything about the DOM. It is a closure over private state — the module pattern — and, like the model, it is pure logic you can run right here:

// Stand-in Task for the playground (in the real app this is Step 1's Task).
function Task(title, id) {
  this.id = id || Date.now() + Math.floor(Math.random() * 1000);
  this.title = title.trim();
  this.status = "pending";
  this.createdAt = new Date().toISOString();
}
Task.prototype.complete = function () { this.status = "completed"; };
Task.prototype.reopen = function () { this.status = "pending"; };

function createTaskManager(initialTasks) {
  // Private state, closed over — nothing outside can touch it directly.
  const tasks = initialTasks || [];
  const listeners = [];

  function emitChange() {
    listeners.forEach((fn) => fn(getTasks()));
  }

  function getTasks() {
    return tasks.slice(); // hand out a copy, never the internal array
  }

  return {
    onChange(fn) {
      listeners.push(fn);
    },
    add(title) {
      const task = new Task(title);
      tasks.push(task);
      emitChange();
      return task;
    },
    toggle(id) {
      const task = tasks.find((t) => t.id === id);
      if (!task) return;
      task.status === "completed" ? task.reopen() : task.complete();
      emitChange();
    },
    remove(id) {
      const index = tasks.findIndex((t) => t.id === id);
      if (index === -1) return;
      tasks.splice(index, 1);
      emitChange();
    },
    getTasks,
    getStats() {
      const total = tasks.length;
      const completed = tasks.filter((t) => t.status === "completed").length;
      return { total, completed, pending: total - completed };
    },
  };
}

// Try it — subscribe, then drive it and watch the events fire.
const manager = createTaskManager();
manager.onChange((tasks) => {
  console.log("changed →", tasks.map((t) => t.title + ":" + t.status));
});

const a = manager.add("Write the renderer");
const b = manager.add("Wire up the form");
manager.toggle(a.id);
console.log("stats:", manager.getStats()); // { total: 2, completed: 1, pending: 1 }
manager.remove(b.id);

The service never mentions document. That separation is the whole point of the module pattern: state and rules live in one place, the UI subscribes via onChange, and either side can be tested or replaced without touching the other. In the assembled app you will hand createTaskManager(loadTasks()) the stored tasks, and its onChange will drive both a save and a re-render.

Capstone milestone

Milestone 2 — persistence and the service. Storage reads and writes real localStorage (guarded against quota, disabled storage, and corrupt JSON), and the manager service owns the task list behind a closure, emitting a change event on every mutation.

Hint: This fuses the storage-layer milestone (lessons 08, 20) with the task-manager-service milestone (lessons 05, 07, 16).

  • saveTasks/loadTasks use localStorage under one key and survive a reload
  • loadTasks rehydrates plain JSON back into real Task instances
  • createTaskManager keeps its tasks array private and exposes add/toggle/remove/getTasks/getStats
  • onChange listeners fire on every add, toggle, and remove

Step 4: The Real DOM Renderer

This is the step the old lesson only pretended to do. The renderer reads the current tasks and builds real elementsdocument.createElement, textContent, and appendChild — into the #task-list you put in the scaffold. No console.log standing in for a screen.

Add this below Step 2's storage functions in your index.html:

// --- DOM references --------------------------------------------------------
const listEl = document.getElementById("task-list");
const statsEl = document.getElementById("task-stats");

// --- Renderer --------------------------------------------------------------
function renderTasks(tasks, stats) {
  // Clear and rebuild. For an app this size, rebuilding is simplest and correct.
  listEl.replaceChildren();

  for (const task of tasks) {
    const li = document.createElement("li");
    li.dataset.taskId = task.id;
    li.dataset.status = task.status; // "pending" | "completed"

    const title = document.createElement("span");
    title.className = "title";
    // textContent, never innerHTML: the title is user input. Assigning it as
    // text means a title like "<img onerror=alert(1)>" shows up as literal
    // text, never as markup. innerHTML here would be an XSS hole.
    title.textContent = task.title;

    const completeBtn = document.createElement("button");
    completeBtn.textContent = task.status === "completed" ? "Reopen" : "Done";
    completeBtn.dataset.action = "toggle";

    const deleteBtn = document.createElement("button");
    deleteBtn.textContent = "Delete";
    deleteBtn.dataset.action = "delete";

    li.append(title, completeBtn, deleteBtn);
    listEl.appendChild(li);
  }

  statsEl.textContent =
    stats.total + " total · " + stats.pending + " pending · " + stats.completed + " done";
}

Two details carry real weight:

  • textContent, never innerHTML, for user input. A task title is text the user typed. Setting it with innerHTML would let a title containing an <img> tag with an error-handler attribute execute as HTML — a cross-site scripting hole. textContent treats it as literal text, always. Make this a reflex, not an afterthought.
  • State lives on the element. Each <li> carries data-task-id and data-status. That is not decoration: the click handler in the next step reads the id straight off the element, and the id + status on the DOM are exactly what an automated check inspects to confirm a task is really marked done.

Step 5: Wiring It Up

The last step connects the pieces: the form adds tasks (with validation), clicks on the list toggle and delete via event delegation, every change saves and re-renders, and on load the app reads from storage and renders immediately.

Add this below Step 4's renderer, at the bottom of the script element:

// --- Wiring ----------------------------------------------------------------
const formEl = document.getElementById("new-task-form");
const titleInput = document.getElementById("new-task-title");
const errorEl = document.getElementById("form-error");

// Build the manager from whatever was persisted last time.
const manager = createTaskManager(loadTasks());

// One subscription drives both persistence and the UI.
manager.onChange((tasks) => {
  saveTasks(tasks);
  renderTasks(tasks, manager.getStats());
});

// Form submit: validate, then add.
formEl.addEventListener("submit", (event) => {
  event.preventDefault(); // stop the browser from reloading the page
  const title = titleInput.value.trim();

  if (title.length === 0) {
    errorEl.textContent = "Please enter a task title.";
    return;
  }
  if (title.length > 100) {
    errorEl.textContent = "Keep titles to 100 characters or fewer.";
    return;
  }

  errorEl.textContent = ""; // clear any previous error
  manager.add(title);
  titleInput.value = "";
  titleInput.focus();
});

// Event delegation: one listener on the list handles every task's buttons,
// including tasks that don't exist yet. Read the action and id off the DOM.
listEl.addEventListener("click", (event) => {
  const button = event.target.closest("button");
  if (!button) return; // clicked the row but not a button

  const li = button.closest("li");
  const id = Number(li.dataset.taskId);

  if (button.dataset.action === "toggle") {
    manager.toggle(id);
  } else if (button.dataset.action === "delete") {
    manager.remove(id);
  }
});

// Boot: render whatever loadTasks gave the manager, so a reload shows your tasks.
renderTasks(manager.getTasks(), manager.getStats());

Read the flow once end to end. A submit is validated against the same rules the Task model enforces (empty, too long) — belt and suspenders, and a chance to show a friendly message in #form-error instead of throwing. Event delegation means the single list listener covers every current and future task, because it reads the id from the element that was actually clicked. And because the one onChange subscription does both saveTasks and renderTasks, there is exactly one path for "something changed": mutate the service, and persistence and the screen follow automatically.

Capstone milestone

Milestone 3 — the renderer and the wiring. Tasks render as real <li> elements with data-task-id and data-status, user input goes in with textContent (never innerHTML), the form validates and shows errors in #form-error, and event delegation on #task-list handles complete/delete. Every mutation saves and re-renders; the app renders from storage on load.

Hint: This is the renderer milestone from 10-dom-and-events, now driving a real page and a real form (25-form-validation).

  • Adding a task via the form appends an <li> with data-status='pending' to #task-list
  • An empty or over-long title shows a message in #form-error and adds nothing
  • Clicking Done flips the <li>'s data-status to 'completed' and updates #task-stats
  • Reloading the page shows the same tasks (they came from localStorage)
  • Titles render with textContent, so no title can inject HTML

The Complete App

Here is the whole thing in one file — the scaffold plus every browser step, assembled and working. Save it as index.html, open it, and use it: add tasks, mark them done, delete them, then reload and watch them come back.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Task Manager</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
    #task-list { list-style: none; padding: 0; }
    #task-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
    #task-list li[data-status="completed"] .title { text-decoration: line-through; color: #888; }
    .title { flex: 1; }
    #form-error { color: #c00; min-height: 1.2em; margin: 0.25rem 0; }
    button { cursor: pointer; }
  </style>
</head>
<body>
  <h1>Task Manager</h1>

  <form id="new-task-form">
    <input id="new-task-title" type="text" placeholder="What needs doing?" autocomplete="off" />
    <button type="submit">Add</button>
  </form>
  <p id="form-error"></p>

  <span id="task-stats"></span>
  <ul id="task-list"></ul>

  <script>
    // --- Task model --------------------------------------------------------
    function Task(title, id) {
      if (typeof title !== "string" || title.trim().length === 0) {
        throw new Error("Task title is required");
      }
      if (title.trim().length > 100) {
        throw new Error("Task title must be 100 characters or fewer");
      }
      this.id = id || Date.now() + Math.floor(Math.random() * 1000);
      this.title = title.trim();
      this.status = "pending";
      this.createdAt = new Date().toISOString();
    }
    Task.prototype.complete = function () { this.status = "completed"; };
    Task.prototype.reopen = function () { this.status = "pending"; };
    Task.prototype.toJSON = function () {
      return { id: this.id, title: this.title, status: this.status, createdAt: this.createdAt };
    };

    // --- Storage -----------------------------------------------------------
    const STORAGE_KEY = "taskman.tasks.v1";

    function saveTasks(tasks) {
      try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
      } catch (error) {
        console.warn("Could not save tasks:", error.message);
      }
    }

    function loadTasks() {
      let raw;
      try {
        raw = localStorage.getItem(STORAGE_KEY);
      } catch (error) {
        console.warn("Could not read tasks:", error.message);
        return [];
      }
      if (!raw) return [];
      try {
        const plain = JSON.parse(raw);
        if (!Array.isArray(plain)) return [];
        return plain.map((data) => {
          const task = new Task(data.title, data.id);
          task.status = data.status === "completed" ? "completed" : "pending";
          task.createdAt = data.createdAt || task.createdAt;
          return task;
        });
      } catch (error) {
        console.warn("Stored tasks were corrupt, starting clean:", error.message);
        return [];
      }
    }

    // --- Service -----------------------------------------------------------
    function createTaskManager(initialTasks) {
      const tasks = initialTasks || [];
      const listeners = [];

      function emitChange() {
        listeners.forEach((fn) => fn(getTasks()));
      }
      function getTasks() {
        return tasks.slice();
      }

      return {
        onChange(fn) { listeners.push(fn); },
        add(title) {
          const task = new Task(title);
          tasks.push(task);
          emitChange();
          return task;
        },
        toggle(id) {
          const task = tasks.find((t) => t.id === id);
          if (!task) return;
          task.status === "completed" ? task.reopen() : task.complete();
          emitChange();
        },
        remove(id) {
          const index = tasks.findIndex((t) => t.id === id);
          if (index === -1) return;
          tasks.splice(index, 1);
          emitChange();
        },
        getTasks,
        getStats() {
          const total = tasks.length;
          const completed = tasks.filter((t) => t.status === "completed").length;
          return { total, completed, pending: total - completed };
        },
      };
    }

    // --- DOM references ----------------------------------------------------
    const listEl = document.getElementById("task-list");
    const statsEl = document.getElementById("task-stats");
    const formEl = document.getElementById("new-task-form");
    const titleInput = document.getElementById("new-task-title");
    const errorEl = document.getElementById("form-error");

    // --- Renderer ----------------------------------------------------------
    function renderTasks(tasks, stats) {
      listEl.replaceChildren();
      for (const task of tasks) {
        const li = document.createElement("li");
        li.dataset.taskId = task.id;
        li.dataset.status = task.status;

        const title = document.createElement("span");
        title.className = "title";
        title.textContent = task.title; // never innerHTML with user input

        const completeBtn = document.createElement("button");
        completeBtn.textContent = task.status === "completed" ? "Reopen" : "Done";
        completeBtn.dataset.action = "toggle";

        const deleteBtn = document.createElement("button");
        deleteBtn.textContent = "Delete";
        deleteBtn.dataset.action = "delete";

        li.append(title, completeBtn, deleteBtn);
        listEl.appendChild(li);
      }
      statsEl.textContent =
        stats.total + " total · " + stats.pending + " pending · " + stats.completed + " done";
    }

    // --- Wiring ------------------------------------------------------------
    const manager = createTaskManager(loadTasks());

    manager.onChange((tasks) => {
      saveTasks(tasks);
      renderTasks(tasks, manager.getStats());
    });

    formEl.addEventListener("submit", (event) => {
      event.preventDefault();
      const title = titleInput.value.trim();
      if (title.length === 0) {
        errorEl.textContent = "Please enter a task title.";
        return;
      }
      if (title.length > 100) {
        errorEl.textContent = "Keep titles to 100 characters or fewer.";
        return;
      }
      errorEl.textContent = "";
      manager.add(title);
      titleInput.value = "";
      titleInput.focus();
    });

    listEl.addEventListener("click", (event) => {
      const button = event.target.closest("button");
      if (!button) return;
      const li = button.closest("li");
      const id = Number(li.dataset.taskId);
      if (button.dataset.action === "toggle") {
        manager.toggle(id);
      } else if (button.dataset.action === "delete") {
        manager.remove(id);
      }
    });

    // Boot from storage.
    renderTasks(manager.getTasks(), manager.getStats());
  </script>
</body>
</html>

That is a complete, real application in one file — under 200 lines, no framework, no build step. It renders the real DOM, persists to real localStorage, and validates a real form. Everything else you learn from here is a variation on this shape.

How Graded Practice Will Attach Here

The milestones above are self-checked today — you tick them off yourself. That is honest about where the tooling is: a separate-origin browser runner that grades real DOM behavior has been built and is at the pull-request stage. No dates promised.

What is worth knowing is why the scaffold's ids and data-attributes look the way they do. They are deliberately aligned with that runner's assertion DSL, so graded checks can attach later without you changing a line of the app you just wrote:

  • element-exists on #task-list and #new-task-form — the shell rendered.
  • event-then-assert: submit the form, then text-includes the new title in #task-list — an add really happened.
  • attribute-equals on data-status — a task marked done carries data-status="completed" on its element, not just a strikethrough style.

When the runner lands, those assertions bind to the exact selectors in your scaffold. Until then, the milestones are the checkpoint.

Key Takeaways

  • Real DOM, not a simulation. Tasks are real <li> elements built with document.createElement and textContent — the renderer touches the actual page, not a printed model of one.
  • Real persistence. State lives in localStorage under one versioned key and survives a reload; every read is wrapped in try/catch for quota, disabled storage, and corrupt JSON.
  • Serialization drops prototypes. JSON.stringify writes data only; loading rehydrates plain objects back into Task instances so their methods return.
  • XSS discipline. User input goes into the page with textContent, never innerHTML — a reflex, not a special case.
  • The module pattern separates state from UI. The service owns tasks behind a closure and emits change events; the UI subscribes. One onChange path does both save and re-render.
  • Event delegation scales. One listener on the list handles every task's buttons, reading the id straight off the clicked element.

What's Next?

You have built a working app. The whole core arc — foundations, language, browser, engineering, and this capstone — ends here. To keep going:

  1. Add features to this app: priorities, due dates, a filter for pending vs. done, an edit-in-place flow. The service's onChange seam makes each one a small change.
  2. Move state to a server: swap localStorage for a fetch to an API. Because storage is one module, only that module changes.
  3. Learn a framework: React, Vue, or Svelte automate the "mutate state, re-render the DOM" loop you just wrote by hand — which is exactly why writing it by hand once is worth it.
  4. Explore TypeScript: give Task, the service, and the DOM handlers real types and let the compiler catch the mistakes you currently catch by testing.

Pro Tip: Build from the data layer outward. Get the model and service right and test them in isolation (they run in a plain console, no browser needed), then add persistence, then build the UI on top. Each layer stays independently testable, and swapping one out — a mock store for a real API, a hand-rolled renderer for a framework — never forces a rewrite of the others.

Next Steps

The core arc ends here — everything past this point is the optional extension tail: deeper language features you can reach for when a project calls for them, in any order. First up is classes and inheritance, the modern class syntax that puts a cleaner face on the prototype model your Task used.

Next lesson

Classes and Inheritance

Learn JavaScript classes and inheritance. Build reusable blueprints with constructors, methods, static fields, and extends for OOP.

25 min