Skip to main content

🛠️ Weekend Project: Advanced Javascript

This is the module's capstone: a real, buildable app that forces you to reach for everything you just learned. Over a weekend you'll build a Task Manager in pure JavaScript — no framework — using classes, ES modules, custom errors, closures, and higher-order functions. You'll build it the way professionals do: in milestones, guided by George Polya's classic four-step problem-solving framework, checking your work against a clear "what good looks like" bar at each stage.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Apply Polya's four-step framework (understand, plan, execute, review) to a real coding task
  • Structure an app into ES modules — models, services, controllers, and views — with clear responsibilities
  • Model domain entities with classes and persist state through a small service layer over localStorage
  • Write robust validation and custom error types, and handle failures without crashing the UI
  • Use higher-order functions and array methods (map, filter, sort, reduce) to filter and sort data
  • Judge your own work against a "what good looks like" checklist

Estimated Time: A weekend (6–10 focused hours)  •  Difficulty: Intermediate

Hands-on: You'll build and ship a working Task Manager, milestone by milestone, then self-assess it against a rubric.

In This Lesson

The Brief & How to Work

Your mission this weekend is to build a Task Manager: a single-page app where a user can create projects, add tasks to them, set priorities and due dates, move tasks through a status workflow, and filter and sort what they see. Everything is saved in the browser so it survives a refresh. No backend, no framework, no build step — just modern JavaScript running from ES modules.

The point is not the app itself (task managers are a dime a dozen). The point is that a task manager is the perfect excuse to use every advanced feature from this module in a setting where it genuinely earns its place:

ConceptWhere it shows up in this build
ClassesTask, Project, and the StorageService / TaskManager
ES modulesEach concern in its own file, wired with import/export
Custom errorsA ValidationError class thrown by the validators
Higher-order functionsfilter, map, find, sort comparators
ClosuresEvent handlers that capture this and per-item state
Destructuring & spreadConstructor options objects and immutable-style updates

⚠️ Work in milestones, not marathons

The single biggest mistake on a project like this is trying to type the whole thing before running any of it. Don't. Build one milestone, open it in the browser, confirm it works, then move on. Each milestone below ends in something you can actually see or test. If milestone 3 is broken, you know the bug is in milestone 3 — not scattered across 500 lines.

Polya's Four Steps

In 1945 the mathematician George Polya wrote How to Solve It, distilling problem-solving into four steps. It was aimed at math students, but it maps perfectly onto software. We'll use it as the spine of this project so you practice a process, not just syntax.

flowchart TD A["1 · Understand
the problem"] --> B["2 · Devise
a plan"] B --> C["3 · Execute
the plan"] C --> D["4 · Review
& reflect"] D -->|"Iterate when reality
disagrees with the plan"| A

Notice the loop back to the start. Real projects are never a clean straight line — you learn something while building that sends you back to rethink. That's not failure; it's the framework working.

💡 Why bother with a framework? Under pressure, beginners jump straight to typing code (step 3) and skip understanding and planning entirely. That's exactly when you build the wrong thing, or paint yourself into a corner. Ten minutes of steps 1 and 2 routinely saves hours in step 3.

Step 1 — Understand the Problem

Before any code, answer the questions that pin down exactly what you're building. Restate the brief in your own words and get concrete about data, operations, and boundaries.

📖 The domain in one sentence

A user has projects; each project holds tasks; each task has a title, description, priority, due date, and a status that moves from to-doin-progresscompleted.

Questions to settle now

  • What data must we store? Tasks and projects, each with a stable unique id.
  • What operations does the user perform? Create, read, update, delete (CRUD) on both, plus filter and sort tasks.
  • How does data persist? The browser's localStorage — no server this weekend.
  • What can go wrong? Empty titles, over-long text, invalid dates, a full or unavailable localStorage. Each needs a graceful answer, not a crash.

Boundaries (say "no" on purpose)

A weekend project succeeds by being small. Deciding what you will not build is as important as deciding what you will. For this build:

  • Single user — no accounts, login, or multi-device sync
  • Client-side only — no backend, no network calls
  • Data lives only in this browser
  • Function over polish — clean and usable beats pixel-perfect
  • Zero dependencies — vanilla JavaScript, so nothing hides how it works

Model the shape of the data

A quick class diagram makes the relationships obvious before you write a line. This is your map for the whole build:

classDiagram class Task { +String id +String title +String description +String priority +String dueDate +String status +String projectId +update(changes) +toJSON() } class Project { +String id +String name +String color +update(changes) +toJSON() } class StorageService { +String key +save(data) +load() +clear() } class TaskManager { +Task[] tasks +Project[] projects +addTask(data) +updateTask(id, changes) +deleteTask(id) +filterTasks(opts) +sortTasks(list, by) } Task "many" --> "1" Project : belongs to TaskManager --> Task : manages TaskManager --> Project : manages TaskManager --> StorageService : persists via

💡 Restate it to a rubber duck

If you can't explain the diagram above out loud in plain English, you don't understand the problem yet. Fix that now, while changes cost nothing but a redrawn box.

Step 2 — Devise a Plan

With the problem understood, decide how to build it. The key architectural decision is to separate concerns so each file has one job. This is the same layered thinking used in large apps, shrunk to weekend size.

Architecture: four layers

LayerResponsibilityFiles
ModelsRepresent a single task or projectTask.js, Project.js
ServicesTalk to localStorageStorageService.js
ControllerBusiness logic: CRUD, filter, sort, persistenceTaskManager.js
ViewsRender the DOM and handle eventsTaskView.js, ProjectView.js
UtilitiesValidation and custom errorsvalidation.js, errors.js

The golden rule of this layout: views never touch localStorage, and the controller never touches the DOM. Each layer only talks to the one next to it. That single discipline is what will keep the code readable.

Project structure

task-manager/
├── index.html
├── css/
│   └── style.css
└── js/
    ├── app.js                 # entry point — wires everything together
    ├── models/
    │   ├── Task.js
    │   └── Project.js
    ├── services/
    │   └── StorageService.js
    ├── controllers/
    │   └── TaskManager.js
    ├── views/
    │   ├── TaskView.js
    │   └── ProjectView.js
    └── utils/
        ├── errors.js
        └── validation.js

💡 Build order = dependency order

Build from the inside out: models and errors first (they depend on nothing), then the storage service and validators, then the controller that uses them, and finally the views and entry point. That way every file you write can be tested the moment it exists.

Step 3 — Build in Milestones

Here is the build broken into five milestones. Each has a clear "done when" checkpoint. Type the code, run it, verify the checkpoint, then continue.

✅ Milestone checklist

  • M1 — Foundations: HTML shell + models + errors. Done when: new Task({title:'x'}) logs a valid object in the console.
  • M2 — Persistence: storage service + validators. Done when: data survives a page refresh.
  • M3 — Controller: TaskManager CRUD. Done when: you can add and delete tasks from the console.
  • M4 — Views: render tasks & projects, wire the forms. Done when: you can add a task with the on-screen form.
  • M5 — Filter & sort: status filter + sort control. Done when: the list reorders live as you change the dropdowns.

Milestone 1 — Foundations: the HTML shell

Start with a semantic page and a single module entry point. The type="module" on the script is what makes import/export work in the browser.

<!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>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <header><h1>Task Manager</h1></header>

    <main class="container">
        <aside class="projects-section">
            <h2>Projects</h2>
            <form class="project-form" id="project-form">
                <input type="text" id="project-name" placeholder="New project name" required>
                <input type="color" id="project-color" value="#3498db">
                <button type="submit">Add Project</button>
            </form>
            <ul id="projects-list" class="projects-list"></ul>
        </aside>

        <section class="tasks-section">
            <div class="task-toolbar">
                <h2 id="current-project-name">All Tasks</h2>
                <select id="filter-status">
                    <option value="all">All statuses</option>
                    <option value="todo">To do</option>
                    <option value="in-progress">In progress</option>
                    <option value="completed">Completed</option>
                </select>
                <select id="sort-by">
                    <option value="dueDate">Due date</option>
                    <option value="priority">Priority</option>
                    <option value="title">Title</option>
                </select>
            </div>

            <form class="task-form" id="task-form">
                <input type="text" id="task-title" placeholder="New task title" required>
                <textarea id="task-description" placeholder="Description"></textarea>
                <div class="form-row">
                    <select id="task-priority">
                        <option value="low">Low</option>
                        <option value="medium" selected>Medium</option>
                        <option value="high">High</option>
                    </select>
                    <input type="date" id="task-due-date">
                </div>
                <button type="submit">Add Task</button>
            </form>

            <ul id="tasks-list" class="tasks-list"></ul>
        </section>
    </main>

    <div id="toast" class="toast" role="status" aria-live="polite"></div>
    <script type="module" src="js/app.js"></script>
</body>
</html>

Now the models. Note the modern touches: an options object with destructured defaults, and crypto.randomUUID() for ids — a built-in browser API that replaces the old Date.now() + Math.random() trick.

// js/models/Task.js
export class Task {
  constructor({
    id = crypto.randomUUID(),
    title,
    description = '',
    priority = 'medium',
    dueDate = null,
    status = 'todo',
    projectId = null,
    createdAt = new Date().toISOString(),
  }) {
    Object.assign(this, { id, title, description, priority, dueDate, status, projectId, createdAt });
  }

  update(changes) {
    Object.assign(this, changes);
    return this;
  }

  toJSON() {
    const { id, title, description, priority, dueDate, status, projectId, createdAt } = this;
    return { id, title, description, priority, dueDate, status, projectId, createdAt };
  }
}
// js/models/Project.js
export class Project {
  constructor({ id = crypto.randomUUID(), name, color = '#3498db', createdAt = new Date().toISOString() }) {
    Object.assign(this, { id, name, color, createdAt });
  }

  update(changes) {
    Object.assign(this, changes);
    return this;
  }

  toJSON() {
    const { id, name, color, createdAt } = this;
    return { id, name, color, createdAt };
  }
}

And the custom error — a real subclass of Error so instanceof works and the controller can tell "the user typed something invalid" apart from "something genuinely broke."

// js/utils/errors.js
export class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;   // which input to highlight
  }
}

M1 checkpoint — run this in the console

import('./js/models/Task.js').then(({ Task }) =>
  console.log(new Task({ title: 'Ship the project' }))
);
// → Task { id: '…uuid…', title: 'Ship the project', status: 'todo', … }

Milestone 2 — Persistence & validation

The StorageService wraps localStorage so the rest of the app never touches it directly. Every operation is inside a try/catch, because storage can fail (private mode, quota exceeded) and we must not let that take down the app.

// js/services/StorageService.js
export class StorageService {
  constructor(key) {
    this.key = key;
  }

  save(data) {
    try {
      localStorage.setItem(this.key, JSON.stringify(data));
    } catch (error) {
      // Re-throw with a friendlier message; the controller decides what to show
      throw new Error(`Could not save "${this.key}": ${error.message}`);
    }
  }

  load() {
    try {
      const raw = localStorage.getItem(this.key);
      return raw ? JSON.parse(raw) : null;
    } catch (error) {
      console.error(`Could not read "${this.key}":`, error);
      return null;   // corrupt data shouldn't crash startup
    }
  }

  clear() {
    localStorage.removeItem(this.key);
  }
}

The validators throw a ValidationError the instant anything is wrong. Throwing (rather than returning false) means the calling code can't accidentally ignore a bad value.

// js/utils/validation.js
import { ValidationError } from './errors.js';

const PRIORITIES = ['low', 'medium', 'high'];
const STATUSES = ['todo', 'in-progress', 'completed'];

export function validateTask(task) {
  if (!task.title?.trim()) {
    throw new ValidationError('Task title is required.', 'title');
  }
  if (task.title.length > 100) {
    throw new ValidationError('Title must be under 100 characters.', 'title');
  }
  if (task.description && task.description.length > 500) {
    throw new ValidationError('Description must be under 500 characters.', 'description');
  }
  if (task.dueDate && Number.isNaN(new Date(task.dueDate).getTime())) {
    throw new ValidationError('Due date is not a valid date.', 'dueDate');
  }
  if (task.priority && !PRIORITIES.includes(task.priority)) {
    throw new ValidationError('Unknown priority.', 'priority');
  }
  if (task.status && !STATUSES.includes(task.status)) {
    throw new ValidationError('Unknown status.', 'status');
  }
}

export function validateProject(project) {
  if (!project.name?.trim()) {
    throw new ValidationError('Project name is required.', 'name');
  }
  if (project.name.length > 50) {
    throw new ValidationError('Project name must be under 50 characters.', 'name');
  }
  if (project.color && !/^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(project.color)) {
    throw new ValidationError('Color must be a hex value like #3498db.', 'color');
  }
}

⚠️ M2 checkpoint

Manually run new StorageService('demo').save([1,2,3]) in the console, refresh the page, then new StorageService('demo').load(). If you get [1, 2, 3] back, persistence works.

Milestone 3 — The controller

The TaskManager is the brain. It owns the in-memory arrays, runs validation, saves after every change, and exposes clean methods to the views. It is deliberately DOM-free — you could drive this entire class from the console or a test.

// js/controllers/TaskManager.js
import { Task } from '../models/Task.js';
import { Project } from '../models/Project.js';
import { StorageService } from '../services/StorageService.js';
import { validateTask, validateProject } from '../utils/validation.js';

export class TaskManager {
  #taskStore = new StorageService('tasks');
  #projectStore = new StorageService('projects');

  constructor() {
    this.tasks = (this.#taskStore.load() ?? []).map((t) => new Task(t));
    this.projects = (this.#projectStore.load() ?? []).map((p) => new Project(p));
    this.currentProjectId = null;

    // Guarantee at least one project to hang tasks on
    if (this.projects.length === 0) {
      this.addProject({ name: 'Inbox', color: '#3498db' });
    }
  }

  #persist() {
    this.#taskStore.save(this.tasks.map((t) => t.toJSON()));
    this.#projectStore.save(this.projects.map((p) => p.toJSON()));
  }

  // --- Tasks ---
  addTask(data) {
    const task = new Task({ ...data, projectId: data.projectId ?? this.currentProjectId });
    validateTask(task);
    this.tasks.push(task);
    this.#persist();
    return task;
  }

  updateTask(id, changes) {
    const task = this.tasks.find((t) => t.id === id);
    if (!task) throw new Error('Task not found.');
    validateTask({ ...task, ...changes });
    task.update(changes);
    this.#persist();
    return task;
  }

  deleteTask(id) {
    this.tasks = this.tasks.filter((t) => t.id !== id);
    this.#persist();
  }

  // --- Projects ---
  addProject(data) {
    const project = new Project(data);
    validateProject(project);
    this.projects.push(project);
    this.#persist();
    return project;
  }

  deleteProject(id) {
    if (this.projects.length === 1) throw new Error('Keep at least one project.');
    this.projects = this.projects.filter((p) => p.id !== id);
    // Re-home orphaned tasks onto the first remaining project
    const fallback = this.projects[0].id;
    this.tasks.forEach((t) => { if (t.projectId === id) t.projectId = fallback; });
    if (this.currentProjectId === id) this.currentProjectId = null;
    this.#persist();
  }

  getProject(id) {
    return this.projects.find((p) => p.id === id);
  }

  // --- Queries: higher-order functions doing the work ---
  filterTasks({ status = 'all', search = '' } = {}) {
    const scope = this.currentProjectId
      ? this.tasks.filter((t) => t.projectId === this.currentProjectId)
      : [...this.tasks];

    return scope
      .filter((t) => status === 'all' || t.status === status)
      .filter((t) =>
        !search ||
        t.title.toLowerCase().includes(search.toLowerCase()) ||
        t.description.toLowerCase().includes(search.toLowerCase())
      );
  }

  sortTasks(list, by = 'dueDate') {
    const rank = { high: 3, medium: 2, low: 1 };
    const comparators = {
      dueDate: (a, b) =>
        (a.dueDate ? new Date(a.dueDate) : Infinity) - (b.dueDate ? new Date(b.dueDate) : Infinity),
      priority: (a, b) => rank[b.priority] - rank[a.priority],
      title: (a, b) => a.title.localeCompare(b.title),
    };
    return [...list].sort(comparators[by] ?? comparators.dueDate);
  }
}

M3 checkpoint

From the console: create a manager, add two tasks, delete one, and confirm manager.tasks.length is 1 and that it survives a refresh. Private fields (#taskStore) mean the storage internals are genuinely inaccessible from outside — try it and you'll get a syntax error.

Milestone 4 — Views & forms

Views turn data into DOM and turn clicks into controller calls. Two techniques carry the weight: event delegation (one listener on the list handles every task's buttons, even ones added later) and building rows with an explicit <template>-style helper rather than pasting raw user text into innerHTML.

⚠️ Never inject user text with innerHTML

A title like <img src=x onerror=alert(1)> becomes a real script if you drop it into innerHTML. Build the structure with innerHTML if you like, but put user-supplied values in with textContent, which can't execute. That one habit closes an entire class of XSS bugs.

// js/views/TaskView.js
export class TaskView {
  constructor(taskManager, notify) {
    this.tm = taskManager;
    this.notify = notify;              // callback to show a toast on error
    this.list = document.getElementById('tasks-list');
    this.form = document.getElementById('task-form');
    this.filter = document.getElementById('filter-status');
    this.sort = document.getElementById('sort-by');
    this.#bind();
  }

  #bind() {
    // Submit — closures capture `this` via arrow functions
    this.form.addEventListener('submit', (e) => {
      e.preventDefault();
      try {
        this.tm.addTask({
          title: document.getElementById('task-title').value,
          description: document.getElementById('task-description').value,
          priority: document.getElementById('task-priority').value,
          dueDate: document.getElementById('task-due-date').value || null,
        });
        this.form.reset();
        this.render();
      } catch (err) {
        this.notify(err.message);      // ValidationError message, user-friendly
      }
    });

    this.filter.addEventListener('change', () => this.render());
    this.sort.addEventListener('change', () => this.render());

    // One delegated listener for every task's buttons
    this.list.addEventListener('click', (e) => {
      const row = e.target.closest('.task-item');
      if (!row) return;
      const { id } = row.dataset;
      if (e.target.matches('.delete')) this.#remove(id);
      if (e.target.matches('.advance')) this.#advance(id);
    });
  }

  #remove(id) {
    if (confirm('Delete this task?')) { this.tm.deleteTask(id); this.render(); }
  }

  #advance(id) {
    const next = { todo: 'in-progress', 'in-progress': 'completed', completed: 'todo' };
    const task = this.tm.tasks.find((t) => t.id === id);
    this.tm.updateTask(id, { status: next[task.status] });
    this.render();
  }

  render() {
    const tasks = this.tm.sortTasks(
      this.tm.filterTasks({ status: this.filter.value }),
      this.sort.value
    );
    this.list.replaceChildren(...tasks.map((t) => this.#row(t)));

    if (tasks.length === 0) {
      const empty = document.createElement('li');
      empty.className = 'empty';
      empty.textContent = 'No tasks yet — add one above.';
      this.list.append(empty);
    }
  }

  #row(task) {
    const li = document.createElement('li');
    li.className = `task-item priority-${task.priority} status-${task.status}`;
    li.dataset.id = task.id;

    // Structure via innerHTML, but USER TEXT via textContent (safe from XSS)
    li.innerHTML = `
      

`; li.querySelector('.title').textContent = task.title; li.querySelector('.desc').textContent = task.description || 'No description'; li.querySelector('.advance').textContent = task.status.replace('-', ' '); const due = task.dueDate ? new Date(task.dueDate).toLocaleDateString() : 'No due date'; const project = this.tm.getProject(task.projectId)?.name ?? 'No project'; li.querySelector('.meta').textContent = `${task.priority} · ${due} · ${project}`; return li; } }

The ProjectView follows the same shape — render a list, delegate clicks, and re-render tasks when the selected project changes. Finally, app.js wires the three pieces together and installs a single safety net for anything that slips through.

// js/app.js
import { TaskManager } from './controllers/TaskManager.js';
import { TaskView } from './views/TaskView.js';
import { ProjectView } from './views/ProjectView.js';

function toast(message) {
  const el = document.getElementById('toast');
  el.textContent = message;
  el.classList.add('show');
  setTimeout(() => el.classList.remove('show'), 4000);
}

const tm = new TaskManager();
const taskView = new TaskView(tm, toast);
const projectView = new ProjectView(tm, taskView, toast);

projectView.render();
taskView.render();

// Last-resort net so a stray bug surfaces as a toast, not a blank screen
window.addEventListener('error', (e) => toast(`Unexpected error: ${e.message}`));

M4 checkpoint

Open index.html. Type a title and hit Add Task — it appears in the list. Submit an empty title — you get a friendly toast instead of a crash. Refresh — your tasks are still there.

Milestone 5 — Filter & sort

The good news: you already built this. Because render() reads the dropdowns and calls filterTasks and sortTasks every time, and both selects re-render on change, filtering and sorting already work live. This is the payoff of putting the query logic in the controller — the view stays a thin, dumb renderer.

✅ M5 checkpoint — you have a working app

Add tasks across two priorities and due dates, then flip the Sort by dropdown between Priority and Due date and watch the list reorder. Switch the status filter and watch rows appear and disappear. That's the full build.

Step 4 — Review & Reflect

Shipping isn't the last step — reviewing is. Polya's fourth step is where you turn a finished task into learning you keep. Work through these deliberately.

Verify it does what the brief asked

Go back to Step 1 and check each requirement against the running app: CRUD on tasks and projects, filter, sort, persistence, graceful validation. Anything missing is a bug, not a preference.

Assess the code, not just the output

  • Separation of concerns: did any view reach into localStorage, or the controller into the DOM? If so, that's your first refactor.
  • Error handling: can you break it? Try a 200-character title, a full localStorage, an emoji in the color field. Each should fail gracefully.
  • Naming: could a stranger guess what filterTasks does without reading it? Good names are documentation that can't go stale.

Reflect on the process

Where did reality disagree with your plan? Maybe you discovered mid-build that tasks needed a fallback project when one is deleted. That loop-back to Step 1 is exactly what Polya predicts — capture what you learned so next weekend's plan is sharper.

📖 Term: technical debt

The prompt-based edit dialogs and inline styles are shortcuts that trade quality for speed — that's technical debt. It's fine to take on deliberately for a weekend project, as long as you can name it and know what "paying it back" would look like.

What Good Looks Like

Use this rubric to grade your own build honestly. A solid weekend project hits every row in the "Good" column; the "Great" column is where the extension challenges take you.

DimensionNeeds workGood ✅Great 🌟
Features CRUD partly works All CRUD + filter + sort + persistence work Plus search, tags, or export/import
Architecture One giant file Clean layers; views never touch storage Layers are unit-testable in isolation
Errors Crashes on bad input Custom ValidationError, friendly toasts Field-level inline validation messages
Modern JS Mostly var and loops Classes, modules, array methods, destructuring Private fields, optional chaining used naturally
Security User text in innerHTML User text via textContent Documented threat model in the README

✅ Definition of done

A stranger can clone your repo, open index.html, create a project and a few tasks, filter and sort them, refresh the page, and find everything exactly as they left it — with no console errors along the way.

How to submit

  1. Push the code to a public GitHub repository.
  2. Write a README.md covering: what it does, the concepts it demonstrates, how to run it, and a short reflection on how Polya's four steps played out.
  3. Deploy it (GitHub Pages, Netlify, or similar — it's fully static) and include the live link.

Extension Challenges

Finished early, or want to reach the "Great" column? Each of these forces a new advanced technique. Pick whichever excites you — they're independent.

  • Export / import: serialize all data to a downloadable JSON file and read it back. Practices Blob, object URLs, and the FileReader API.
  • Tags: let a task carry multiple tags and filter by them. Practices Set and multi-criteria filtering.
  • Due-date notifications: warn when a task is due soon using the Notifications API. Practices permissions and async/await.
  • Stats dashboard: show completion rate and a breakdown by status using reduce to aggregate.
  • Undo: keep a short history stack so the last delete can be reversed. Practices closures and immutable snapshots.

Summary & Quiz

🎉 Key Takeaways

  • Process beats panic: Polya's understand → plan → execute → review turns a vague brief into a shippable app.
  • Build in milestones: each stage ends in something you can run and verify, so bugs stay local.
  • Separate concerns: models, a storage service, a DOM-free controller, and thin views keep the code readable as it grows.
  • Errors are features: a custom ValidationError plus try/catch at the edges means bad input becomes a friendly message, not a crash.
  • Grade yourself: a "what good looks like" rubric tells you when you're actually done.

🎯 Quick Quiz

Question 1: In this architecture, which layer is allowed to call localStorage?

Question 2: Why do the validators throw a ValidationError instead of returning false?

Question 3: Why does the view set user-supplied titles with textContent rather than innerHTML?

📚 Further Reading

🚀 What's Next?

You've now used advanced JavaScript to build a complete app that manipulates the page. Next module we go deeper into the tool that made that possible — the DOM — starting with how the browser turns your HTML into a navigable tree of nodes.

🎉 Capstone complete!

You didn't just learn advanced JavaScript — you shipped something with it. That's the difference between knowing syntax and being a developer.