🛠️ 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:
| Concept | Where it shows up in this build |
|---|---|
| Classes | Task, Project, and the StorageService / TaskManager |
| ES modules | Each concern in its own file, wired with import/export |
| Custom errors | A ValidationError class thrown by the validators |
| Higher-order functions | filter, map, find, sort comparators |
| Closures | Event handlers that capture this and per-item state |
| Destructuring & spread | Constructor 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.
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-do → in-progress → completed.
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:
💡 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
| Layer | Responsibility | Files |
|---|---|---|
| Models | Represent a single task or project | Task.js, Project.js |
| Services | Talk to localStorage | StorageService.js |
| Controller | Business logic: CRUD, filter, sort, persistence | TaskManager.js |
| Views | Render the DOM and handle events | TaskView.js, ProjectView.js |
| Utilities | Validation and custom errors | validation.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:
TaskManagerCRUD. 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
filterTasksdoes 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.
| Dimension | Needs work | Good ✅ | 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
- Push the code to a public GitHub repository.
- Write a
README.mdcovering: what it does, the concepts it demonstrates, how to run it, and a short reflection on how Polya's four steps played out. - 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 theFileReaderAPI. - Tags: let a task carry multiple tags and filter by them. Practices
Setand 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
reduceto 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
ValidationErrorplustry/catchat 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
- MDN — JavaScript Reference
- MDN — Web Storage API
- Polya, How to Solve It
- The Modern JavaScript Tutorial
🚀 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.