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. Before reading on, work out what that implies for the trip back.

Predict

A Task goes out to storage as text and comes back in. Predict all four logs before running. The first three print a value; the fourth line is the one to think hardest about — does it print something, or does it fail?

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

const task = new Task("Write the renderer");
const roundTripped = JSON.parse(JSON.stringify(task));

console.log(roundTripped.title);
console.log(roundTripped.status);
console.log(typeof roundTripped.complete);
roundTripped.complete();

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 03-functions, 06-objects-and-prototypes and 09-error-handling 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 thing — localStorage.setItem / getItem, and the data genuinely survives closing the tab.

One note on where this capability came from. localStorage is a Web API — it belongs to the browser, not to the JavaScript language — and it is extension-tier material that was deliberately pulled forward to 20-local-storage-and-state, ahead of this capstone rather than in the optional tail, for one reason: a task manager that forgets everything on refresh is not a task manager. This step is where that decision pays off.

The storage layer is also asynchronous, and that is a design choice worth stating rather than stumbling over. localStorage itself is synchronous, so wrapping it in a promise buys nothing today — but it buys everything tomorrow. The moment you swap this module for a fetch to a real server (the second suggestion under What's Next), the call becomes genuinely async. Writing the seam as async now means that swap touches this module and nothing else; writing it synchronously now would mean rewriting every caller later. So loadTasks() and saveTasks() return Promises, callers await them, and the interface stays honest about what it will eventually be.

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

// --- Storage ---------------------------------------------------------------
// The seam is async on purpose: swapping localStorage for a server call later
// changes this module and nothing above it. See the note above.
const STORAGE_KEY = "taskman.tasks.v1";

async 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);
  }
}

async 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 [];
  }
}

Four 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 loadTasks falls back to an empty list in every failure path rather than showing a blank white screen. An app that starts empty is recoverable; one that throws on boot is not.
  • Both functions are async, so both return Promises. An async function returns a promise whatever its body does, which is why the synchronous localStorage calls inside change nothing about the interface. Callers await loadTasks() — you will see that in Step 5.
  • Neither function ever rejects, and that is part of the contract. Each one catches its own failures and degrades — loadTasks to an empty list, saveTasks to a console warning — so the promise they hand back always resolves. That is deliberate: a failed save should not be able to take down the render that follows it. The consequence for callers is concrete, so it is worth stating plainly rather than discovering later: attaching a .catch to saveTasks(...) is dead code, because there is nothing that can reach it. If you ever change saveTasks to rethrow — which is reasonable if you want the UI to show a "could not save" banner — then the callers must gain that .catch in the same edit. The handler and the swallow belong to one decision.
  • This actually persists. Unlike the old fake-delay store, reloading the page and awaiting loadTasks() returns what you saved. You will see that at the end of the lesson.

Those guards are easy to read past, because on healthy data every one of them is a no-op. The block below removes the shape check and adds a migration requirement: an older version stored a keyed object, and those two valid tasks must survive the upgrade.

Debug

This is loadTasks with its shape check removed, running against a legacy keyed object that contains two valid tasks. The loader warns and returns an empty list, then the assertion fails because both tasks have disappeared. Explain which operation throws and why the catch calls valid legacy data corrupt. Repair the loader to accept healthy arrays and safely migrate the legacy object's values, validating the records before rebuilding Task instances. Both original tasks must survive; returning an empty list for this valid legacy payload is not a fix.

const assert = require('assert');

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

// Stand-in for localStorage: what the browser handed back on this load.
// An older version of the app stored a keyed object, not an array.
const storedRaw = '{"1":{"title":"Ship the renderer"},"2":{"title":"Write the docs"}}';

function loadTasks(raw) {
if (!raw) return [];
try {
	const plain = JSON.parse(raw);
	return plain.map((data) => new Task(data.title));
} catch (error) {
	console.warn("Stored tasks were corrupt, starting clean:", error.message);
	return [];
}
}

const tasks = loadTasks(storedRaw);

assert.strictEqual(
tasks.length,
2,
'expected the two stored tasks to load, got ' + tasks.length,
);
console.log("Loaded " + tasks.length + " tasks");

Expected output:

Loaded 2 tasks

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(await loadTasks()) the stored tasks, and its onChange will drive both a save and a re-render.

Arrange the code

Reassemble the core of loadTasks: the four lines that turn whatever string was in storage into usable Task objects. It is called twice — once with data that is corrupt (an object where a list was expected) and once with a healthy list. Only one arrangement handles both. Why must these run in this order, and which line is doing work that is invisible until the data is wrong?

  1. tasks[0].complete();
  2. if (!Array.isArray(plain)) return "RECOVERED empty list";
  3. const tasks = plain.map((data) => new Task(data.title));
  4. const plain = JSON.parse(raw);

Capstone milestone

Milestone 2 — persistence and the service. Storage reads and writes real localStorage behind an async seam — loadTasks and saveTasks return Promises, every access is wrapped in try/catch, and loadTasks falls back to an empty list when the read fails. The manager service owns the task list behind a closure, emitting a change event on every mutation.

Hint: This fuses the storage-layer milestone (08-async-and-promises, 20-local-storage-and-state) with the task-manager-service milestone (05-data-structures, 07-closures-and-scope, 16-array-methods).

  • saveTasks/loadTasks return Promises, are awaited by their callers, and use localStorage under one key so tasks 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

Recall

Without scrolling up: createTaskManager keeps its tasks in a plain const inside the function and returns an object of methods, and getTasks hands back tasks.slice() rather than tasks itself. Drawing on the earlier lessons this design came from — what makes the task list genuinely private rather than private by convention, and what would change if getTasks returned the array directly?

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 elements — document.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.
  // replaceChildren() with no arguments removes every child in one call. It is a
  // newer API than the removeChild loop from 10-dom-and-events and does the same
  // job; if you prefer the idiom you already know, while (listEl.firstChild)
  // listEl.removeChild(listEl.firstChild); is exactly equivalent here.
  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");

// loadTasks() is async, so the manager cannot be built until it resolves.
// Everything that depends on it lives inside start().
let manager;

async function start() {
  // Build the manager from whatever was persisted last time.
  manager = createTaskManager(await loadTasks());

  // One subscription drives both persistence and the UI. saveTasks handles its
  // own failures, so there is nothing to catch here.
  manager.onChange((tasks) => {
    saveTasks(tasks);
    renderTasks(tasks, manager.getStats());
  });

  // Boot: render whatever loadTasks gave the manager, so a reload shows your tasks.
  renderTasks(manager.getTasks(), 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);
  }
});

start();

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.

Build It Yourself: The Render Pipeline

Everything above this point you have read. This is the part you write.

Step 4 handed you a finished renderTasks, and reading a renderer is not the same as building one. This is a build task: the render pipeline, rebuilt by you, reporting its own pass/fail. It runs right here in the playground rather than in your index.html, and that requires one substitution — the playground has no document, so the starter supplies a small fake one that records the same calls the real DOM would receive (createElement, textContent, dataset, appendChild). The logic you write is the logic the real renderer runs; only the object it talks to is a stand-in. Keep :436's version as your reference, but write these three from the spec rather than copying it — the checks test different things than a copy would.

Three functions are stubbed and the checks below them fail until each behaves. Run it as-is and it fails at TODO 1. Implement each until it prints All checks passed.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each returns the right thing. Spec: buildTaskElement(task) returns one li carrying the task id on dataset.taskId and the status on dataset.status, holding a single span with className 'title' whose textContent is the task title. renderTasks(listEl, tasks) clears the list and appends one built li per task, in order, so that rendering twice does not double the rows. statsLine(tasks) returns the line '2 total, 1 pending, 1 done' for the fixed data. The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. One check deliberately renders a title containing markup: it must come back as literal text.

const assert = require('assert');

// A minimal stand-in for the DOM. The real renderer in this lesson calls
// document.createElement, textContent, dataset and appendChild; this fake
// records the same calls so the pipeline can be checked without a browser.
// Do NOT change this.
function createFakeDocument() {
function createElement(tag) {
	return {
		tag,
		textContent: "",
		className: "",
		dataset: {},
		children: [],
		appendChild(child) { this.children.push(child); return child; },
		append(...kids) { for (const k of kids) this.children.push(k); },
		replaceChildren() { this.children = []; },
	};
}
return { createElement };
}

const fakeDocument = createFakeDocument();
const listEl = fakeDocument.createElement("ul");

// Fixed data. Do NOT change these.
const tasks = [
{ id: 11, title: "Ship the renderer", status: "pending" },
{ id: 22, title: "<img onerror=alert(1)>", status: "completed" },
];

// TODO 1: build ONE <li> for a task and return it. It must carry the task's id
// on li.dataset.taskId and its status on li.dataset.status, and hold one child
// span whose className is "title" and whose textContent is the task's title.
// Use fakeDocument.createElement and textContent — never innerHTML.
//   buildTaskElement({ id: 7, title: "Hi", status: "pending" })
//     -> { tag: "li", dataset: { taskId: 7, status: "pending" },
//          children: [ { tag: "span", className: "title", textContent: "Hi" } ] }
function buildTaskElement(task) {
// your code here
}

// TODO 2: clear the list element, then append one built <li> per task, in order.
// Clearing first is what makes a re-render idempotent — calling it twice must
// not double the rows.
//   renderTasks(listEl, tasks) -> listEl.children.length === 2
function renderTasks(listEl, tasks) {
// your code here
}

// TODO 3: return the stats line the app shows, exactly in this shape:
//   "2 total, 1 pending, 1 done"
function statsLine(tasks) {
// your code here
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const one = buildTaskElement({ id: 7, title: "Hi", status: "pending" });
assert.strictEqual(one.tag, "li", "TODO 1: buildTaskElement must return an li element");
assert.strictEqual(
one.dataset.taskId,
7,
"TODO 1: the li must carry the task id on dataset.taskId, which is how the click handler finds it",
);
assert.strictEqual(
one.dataset.status,
"pending",
"TODO 1: the li must carry the task status on dataset.status",
);
assert.strictEqual(one.children.length, 1, "TODO 1: the li must hold exactly one title span");
assert.strictEqual(one.children[0].className, "title", "TODO 1: the span needs className 'title'");
assert.strictEqual(
one.children[0].textContent,
"Hi",
"TODO 1: the title must be set as textContent",
);

renderTasks(listEl, tasks);
assert.strictEqual(listEl.children.length, 2, "TODO 2: renderTasks must append one li per task");
assert.strictEqual(
listEl.children[0].dataset.taskId,
11,
"TODO 2: tasks must be appended in order, first task first",
);
renderTasks(listEl, tasks);
assert.strictEqual(
listEl.children.length,
2,
"TODO 2: rendering twice must not double the rows — clear the list before rebuilding",
);
assert.strictEqual(
listEl.children[1].children[0].textContent,
"<img onerror=alert(1)>",
"TODO 1/2: a title containing markup must survive as literal TEXT, never as elements",
);
assert.strictEqual(
statsLine(tasks),
"2 total, 1 pending, 1 done",
"TODO 3: statsLine must count total, pending and done in that order",
);

console.log("All checks passed.");
console.log("Rows rendered:", listEl.children.length);
console.log("Stats:", statsLine(tasks));
console.log("Second row title:", listEl.children[1].children[0].textContent);

Expected output:

All checks passed.
Rows rendered: 2
Stats: 2 total, 1 pending, 1 done
Second row title: <img onerror=alert(1)>

Once it passes, try two variations and predict each before running:

  1. Drop the clear. Remove listEl.replaceChildren() from renderTasks, then predict which check breaks and what number it reports. Predict before running. The first two checks still pass — a single render is unaffected — and the third fails, reporting 4 rows where 2 were expected, because the second render appended to the first instead of replacing it. An instructive failure: it shows why "it looked right when I tested it once" is not evidence for a re-render.
  2. Swap textContent for a markup assignment. In buildTaskElement, change the one line title.textContent = task.title; to title.innerHTML = task.title; and predict which check fails first, and what it reports. Predict before running. The tempting answer is the XSS check near the bottom — but execution never gets that far. It fails at the very first check that inspects a title, TODO 1's the title must be set as textContent, reporting actual: '' against expected: 'Hi', because textContent was never assigned and the fake element still holds its initial empty string. That is worth more than the XSS failure would have been: it shows that the battery catches the mistake at the earliest point it can observe it, and it is a reminder that "which assert fires" is decided by check order, not by which consequence is most severe. In a real browser this same one-word edit is a live script-injection hole — a task titled <img onerror=...> would execute — and no assert would have told you, because the DOM would have accepted it happily. Cheap to get wrong, expensive to discover: that asymmetry is why textContent for user input is a reflex rather than a review item.

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

Transfer

The invariant your service holds: it owns the tasks and the rules, it emits a change event when anything moves, and it never mentions document. New requirement — the same task manager must also run as a command-line tool that prints the list to a terminal, with no browser and no DOM anywhere. How much of what you built survives, and what does that tell you about where the boundary was drawn?

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";

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

    async 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 ------------------------------------------------------------
    let manager;

    async function start() {
      manager = createTaskManager(await loadTasks());

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

      // Boot from storage.
      renderTasks(manager.getTasks(), 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);
      }
    });

    start();
  </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.

One deliberate omission, so you are not surprised by it. The app is a single <script> element with no import or export anywhere, even though 11-modules-and-bundling taught ES modules four lessons ago and the four labelled sections — model, storage, service, renderer — are exactly the split you would put in four files. That is a teaching choice, not a recommendation: a single file opens from your filesystem with a double-click, whereas ES modules require type="module" and a local server, because a browser will not load a module over file://. The boundaries are real either way; they are just marked with comments here instead of being enforced by the module system. Turning it into four modules is a genuinely useful exercise, and because nothing crosses the layers except the names each section deliberately exposes, it is a mechanical change rather than a redesign — which is itself the evidence that the boundaries were doing their job.

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, behind an async seam. 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, and a failed read falls back to an empty list. loadTasks/saveTasks return Promises so the module can become a server call without any caller changing.
  • 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?

Definition of done

"Finished" needs to mean something more specific than "I stopped working on it." This project is done when all six of these are true, checked in your own browser rather than assumed:

  1. It survives a reload. Add three tasks, mark one done, refresh the page. All three come back, with the same one still marked done.
  2. It refuses bad input visibly. Submitting an empty title, or one over 100 characters, puts a message in #form-error and adds no row — rather than adding nothing and saying nothing.
  3. Every task is operable. Done and Delete work on a task you just added and on one that came back from storage after a reload. (These are different code paths: one row was built from a live object, the other from rehydrated JSON.)
  4. Corrupt storage does not brick it. Put junk in the storage key from the console — localStorage.setItem("taskman.tasks.v1", "{oops") — and reload. The app starts empty and usable, and warns in the console; it does not show a blank page.
  5. A title is never markup. Add a task called <b>bold</b> and confirm it renders as those literal characters, not as bold text.
  6. The stats line agrees with the list. The totals match what you can count on screen after an add, a toggle and a delete.

If any of the six fails, that is the next thing to fix — not a new feature.

Reflect before you extend

Spend ten minutes on these before adding anything. Writing the answers down is worth more than thinking them, because the vague ones become visible on the page:

  • Where did you put a decision, and why there? The title length limit is enforced in two places — the Task constructor and the submit handler. Was that duplication or defence in depth? What breaks if you remove either one?
  • Which tradeoff would you make differently now? The renderer clears and rebuilds the whole list on every change. That is simple and correct, and it is also more work than necessary for a thousand tasks. At what point would you switch, and what would you need to measure to know you had reached it?
  • What was hardest to get right, and what would have made it easier? For most people it is the rehydration step or the delegated click handler. Was the difficulty in the concept or in the debugging — and which of 12-testing-and-debugging's routines would have found it faster?
  • What would you have to change to support two users? Follow the change through the layers and notice which ones are untouched. The layers that do not move are the ones whose boundaries you drew well.

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. This is what the async seam was for — loadTasks and saveTasks already return Promises and their callers already await them, so the bodies change and nothing above them does.
  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