The Story: Ananya’s Exam Weeks
Ananya is in her third semester of B.Sc. Her exams start in three weeks. She has five subjects, a pile of notes and a small notebook where she writes tasks like “Revise Physics chapter 3” and “Solve 20 maths problems”. The notebook gets messy fast: tasks are crossed out, rewritten and lost.
In this project you will build Ananya a Study Planner: a small to-do app that runs in the browser. She can add a task, tick it when it is done, delete it, see only pending or done tasks, and see how many tasks are left. Her list is saved in the browser, so it is still there tomorrow.
A to-do app sounds simple, but it uses almost every core JavaScript skill: arrays and objects for the data, DOM methods to draw the list, events for every click, and localStorage with JSON to remember things.
How to follow this project: every step below is a complete page. Press ▶ Try it yourself on each one, play with it, then read what changed. Each step adds one feature to the step before it.
The Plan: Data First, Then the Screen
The most important idea in this project is simple: the array is the truth, and the page is only a picture of the array. We never read tasks back from the screen. Whenever something changes, we change the array, then call one function, render(), that redraws the list from the array.
| Step | What you add | Main skill |
|---|---|---|
| 1 | The layout | HTML, CSS, querySelector |
| 2 | Tasks in an array, drawn by render() | Arrays of objects, createElement |
| 3 | Add a task | The submit event |
| 4 | Mark done and delete | Event delegation |
| 5 | “Tasks left” counter | filter and length |
| 6 | All / Pending / Done filter | App state |
| 7 | Save in the browser | localStorage and JSON |
Step 1: Build the Layout
Start with plain HTML and CSS: a card with a heading, a form with one text box and an Add button, and a list. The two list items are written by hand for now, so you can see how the finished list will look.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<ul id="task-list">
<li><span>Revise Physics chapter 3</span></li>
<li><span>Solve 20 maths problems</span></li>
</ul>
</div>
<script>
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
console.log('Planner ready. Items on the page:', list.children.length); // 2
</script>
</body>
</html>
The script only selects the three elements we will use in every step: the form, the input and the list. list.children.length counts the items inside the list, so the console shows 2. Nothing is interactive yet: get the layout right first, then add behaviour.
Notice the button has type="submit" and sits inside the form. That means pressing Enter in the text box also adds a task later, which feels natural on a phone keyboard too.
Step 2: Store Tasks in an Array and Render Them
Now we remove the hand-written items. Each task becomes an object with three properties: an id to identify it, the text, and done, which is true or false. All tasks live in one array called tasks.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
</div>
<script>
let tasks = [
{ id: 1, text: 'Revise Physics chapter 3', done: false },
{ id: 2, text: 'Solve 20 maths problems', done: true },
{ id: 3, text: 'Make a formula sheet for Chemistry', done: false }
];
const list = document.querySelector('#task-list');
function render() {
list.innerHTML = '';
for (const task of tasks) {
const li = document.createElement('li');
li.classList.toggle('done', task.done);
const span = document.createElement('span');
span.textContent = task.text;
li.append(span);
list.append(li);
}
}
render();
</script>
</body>
</html>
Line by line, here is what render() does:
list.innerHTML = ''empties the list, so we always start from a clean slate.for (const task of tasks)visits every object in the array.document.createElement('li')makes a new list item in memory.li.classList.toggle('done', task.done)adds the classdoneonly when the second argument istrue. The CSS then draws a line through finished tasks.span.textContent = task.textputs the task text in, as plain text.appendputs the span inside the item and the item inside the list.
We use let for tasks because in Step 4 we will replace the whole array when deleting. Set the first task’s done to true and run again: the line appears.
Step 3: Add a Task with the Form
Ananya types a task and presses Add. We listen for the form’s submit event, not the button’s click, because submit also fires when she presses Enter.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
</div>
<script>
let tasks = [
{ id: 1, text: 'Revise Physics chapter 3', done: false },
{ id: 2, text: 'Solve 20 maths problems', done: true },
{ id: 3, text: 'Make a formula sheet for Chemistry', done: false }
];
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
function render() {
list.innerHTML = '';
for (const task of tasks) {
const li = document.createElement('li');
li.classList.toggle('done', task.done);
const span = document.createElement('span');
span.textContent = task.text;
li.append(span);
list.append(li);
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
render();
});
render();
</script>
</body>
</html>
event.preventDefault()stops the browser’s normal behaviour, which is to send the form and reload the page. Without it, the new task would vanish at once.input.value.trim()reads the text and removes spaces at the start and end. If the result is an empty string, we put the cursor back in the box andreturn, so empty tasks are ignored.Date.now()gives the number of milliseconds since 1 January 1970. It is different every time you click, so it works well as a simple uniqueid.- After
push, we clear the box, keep the focus in it for the next task, and callrender().
Try pressing Add with only spaces in the box. Nothing happens, which is exactly what we want.
Step 4: Mark Done and Delete with Event Delegation
Each row now gets a checkbox and a Delete button. Here is the tricky part: render() throws away all rows and makes new ones every time. If we attached a click listener to each button, those listeners would be thrown away too, and we would need to attach them again after every render.
The clean solution is event delegation. Clicks bubble up from the element you clicked to its parents. So we put one listener on the <ul>, which never changes, and inside it we check what was clicked. You met bubbling in the JavaScript Events lesson.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
</div>
<script>
let tasks = [
{ id: 1, text: 'Revise Physics chapter 3', done: false },
{ id: 2, text: 'Solve 20 maths problems', done: true },
{ id: 3, text: 'Make a formula sheet for Chemistry', done: false }
];
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
function render() {
list.innerHTML = '';
for (const task of tasks) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
render();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
render();
});
render();
</script>
</body>
</html>
li.dataset.id = task.idwrites adata-idattribute on each row, so the row remembers which task it shows.event.targetis the exact element clicked.closest('li')walks up to the nearest row.Number(li.dataset.id)turns the id back into a number. Attributes are always strings, and'5' === 5isfalse.- For the checkbox,
findreturns the matching task object and we flipdonewith!. - For Delete,
filterbuilds a new array with every task except the clicked one. That is whytasksuseslet. - A click on the text itself matches neither class, so we
returnwithout redrawing.
Rows added later work straight away, because the one listener on the list handles them all.
Step 5: Show How Many Tasks Are Left
Ananya wants a quick answer to “How much is left?”. We add a paragraph under the list and update it at the end of render().
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
.footer { margin: 12px 0 0; color: #6b7280; font-size: 0.95rem; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
<p class="footer" id="count"></p>
</div>
<script>
let tasks = [
{ id: 1, text: 'Revise Physics chapter 3', done: false },
{ id: 2, text: 'Solve 20 maths problems', done: true },
{ id: 3, text: 'Make a formula sheet for Chemistry', done: false }
];
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
function render() {
list.innerHTML = '';
for (const task of tasks) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
const left = tasks.filter((t) => !t.done).length;
count.textContent = left === 1 ? '1 task left' : `${left} tasks left`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
render();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
render();
});
render();
</script>
</body>
</html>
tasks.filter((t) => !t.done) keeps only the unfinished tasks, and .length counts them. The conditional (ternary) operator picks the right words: “1 task left” for one, and a template string such as “2 tasks left” for any other number, including zero.
Adding, ticking and deleting all call render(), so the counter is always correct.
Step 6: Filter: All, Pending, Done
On the night before an exam, Ananya only wants to see what is still pending. We add three filter buttons and one new piece of state: the variable currentFilter, which holds 'all', 'pending' or 'done'.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
.footer { margin: 12px 0 0; color: #6b7280; font-size: 0.95rem; }
.filters { display: flex; gap: 6px; margin-top: 16px; }
.filters button { background: #e5e7eb; color: #1f2937; padding: 6px 12px; font-size: 0.9rem; }
.filters button.active { background: #4f46e5; color: #ffffff; }
li.empty { color: #6b7280; justify-content: center; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active">All</button>
<button type="button" data-filter="pending">Pending</button>
<button type="button" data-filter="done">Done</button>
</div>
<ul id="task-list"></ul>
<p class="footer" id="count"></p>
</div>
<script>
let tasks = [
{ id: 1, text: 'Revise Physics chapter 3', done: false },
{ id: 2, text: 'Solve 20 maths problems', done: true },
{ id: 3, text: 'Make a formula sheet for Chemistry', done: false }
];
let currentFilter = 'all';
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');
function visibleTasks() {
if (currentFilter === 'pending') return tasks.filter((t) => !t.done);
if (currentFilter === 'done') return tasks.filter((t) => t.done);
return tasks;
}
function render() {
list.innerHTML = '';
const shown = visibleTasks();
if (shown.length === 0) {
const li = document.createElement('li');
li.className = 'empty';
li.textContent = 'Nothing here. Enjoy a short break!';
list.append(li);
}
for (const task of shown) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
const left = tasks.filter((t) => !t.done).length;
count.textContent = left === 1 ? '1 task left' : `${left} tasks left`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
render();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
render();
});
filters.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
currentFilter = button.dataset.filter;
for (const b of filters.querySelectorAll('button')) {
b.classList.toggle('active', b === button);
}
render();
});
render();
</script>
</body>
</html>
visibleTasks()returns the tasks to show. Thetasksarray itself is never changed by filtering: we only choose which items to draw.- The filter buttons use delegation too. Each button has a
data-filterattribute, andbutton.dataset.filterreads it. - The loop gives the
activeclass to the clicked button and removes it from the others, so the learner can see which filter is on. - When nothing matches, we show a friendly “Nothing here” row instead of an empty box. It has no
data-id, and the list’s click handler simply ignores it.
The counter still counts all pending tasks, not just the visible ones, because it uses tasks, not shown.
Step 7: The Complete App, Saved with localStorage
Right now, closing the tab loses everything. localStorage is a small storage space in the browser that keeps text even after you close the browser. It can only store strings, so we convert the array to JSON text with JSON.stringify when saving, and back into an array with JSON.parse when loading. The JavaScript Local Storage lesson covers this in detail.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
.footer { margin: 12px 0 0; color: #6b7280; font-size: 0.95rem; }
.filters { display: flex; gap: 6px; margin-top: 16px; }
.filters button { background: #e5e7eb; color: #1f2937; padding: 6px 12px; font-size: 0.9rem; }
.filters button.active { background: #4f46e5; color: #ffffff; }
li.empty { color: #6b7280; justify-content: center; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active">All</button>
<button type="button" data-filter="pending">Pending</button>
<button type="button" data-filter="done">Done</button>
</div>
<ul id="task-list"></ul>
<p class="footer" id="count"></p>
</div>
<script>
const STORAGE_KEY = 'study-planner-tasks';
function loadTasks() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return [];
try {
return JSON.parse(saved);
} catch {
return [];
}
}
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function update() {
saveTasks();
render();
}
let tasks = loadTasks();
let currentFilter = 'all';
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');
function visibleTasks() {
if (currentFilter === 'pending') return tasks.filter((t) => !t.done);
if (currentFilter === 'done') return tasks.filter((t) => t.done);
return tasks;
}
function render() {
list.innerHTML = '';
const shown = visibleTasks();
if (shown.length === 0) {
const li = document.createElement('li');
li.className = 'empty';
li.textContent = 'Nothing here. Enjoy a short break!';
list.append(li);
}
for (const task of shown) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
const left = tasks.filter((t) => !t.done).length;
count.textContent = left === 1 ? '1 task left' : `${left} tasks left`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
update();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
update();
});
filters.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
currentFilter = button.dataset.filter;
for (const b of filters.querySelectorAll('button')) {
b.classList.toggle('active', b === button);
}
render();
});
render();
</script>
</body>
</html>
loadTasks()reads the saved text. The first time, there is nothing saved, sogetItemreturnsnulland we start with an empty array.- The
try...catchprotects us if the saved text is broken: instead of crashing, the app starts empty. update()saves and then renders. We call it after every change to the data (add, tick, delete). Changing the filter only callsrender(), because the data did not change.STORAGE_KEYis the name of the saved item. A clear, unique name avoids clashes with other apps.
About the Try it yourself box: the playground gives you a temporary, in-memorylocalStorage. Your tasks survive while you use the box, but they disappear when you reset or reload it. Save the code asindex.htmlon your computer and open it in Chrome or Firefox to see tasks stay after closing the browser.
Real-Life Example: Ananya’s Planner in Use
On Monday Ananya adds “Revise Physics chapter 3”, “Solve 20 maths problems” and “Read English poem notes”. The counter says 3 tasks left. She finishes maths, ticks it, and the line appears. On Tuesday she opens the same file, and the list is still there, because it was saved. Before sleeping she picks Pending to see only what remains. This is the complete app from Step 7.
Try these changes:
- Change the placeholder text and the heading to suit your own subjects.
- Change the empty message to show a different line for each filter, for example “No finished tasks yet” when the Done filter is on.
- Show the total count too, such as “2 of 5 tasks left”.
Ideas to Take It Further
- Subjects: add a
<select>for the subject (Physics, Maths, English) and show it as a small coloured label on each task. - Due dates: add an
<input type="date">and sort tasks so the nearest date comes first. - Edit a task: on double-click, swap the text for an input box and save the new text on Enter.
- Progress bar: show the percentage of tasks done as a bar that fills up.
Common Mistakes
- Forgetting
event.preventDefault(). The form reloads the page and the new task disappears in a flash. Fix: call it as the first line of the submit handler. - Adding listeners inside
render()or to each button. The rows are rebuilt on every render, so listeners get lost or added twice. Fix: one delegated listener on the list, added once. - Comparing a string id with a number id.
datasetalways gives strings, sofindreturnsundefinedand the next line throws an error. Fix: convert withNumber(), as this example shows:
// dataset values are always strings
const idFromPage = '1712345';
const idInArray = 1712345;
console.log(idFromPage === idInArray); // false
console.log(Number(idFromPage) === idInArray); // true
- Saving the array without JSON.
localStorage.setItem('tasks', tasks)saves the text[object Object], and your data is lost. Fix: alwaysJSON.stringifybefore saving andJSON.parseafter loading. - Using
innerHTMLfor the task text. If someone types<b>or a script tag, the browser treats it as HTML. Fix: usetextContentfor anything the user typed, as we did.
Practice Task: Add a “Clear completed” Button
After a good study day, Ananya has many ticked tasks. Add a Clear completed button that removes every done task in one click. The button is already in the HTML below. Complete the two TODO lines in the script.
- Select the button with the id
clear-done. - On click, keep only the tasks where
doneisfalse. - Save and redraw by calling
update(). - Check that the counter and the filter still work afterwards.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
.footer { margin: 12px 0 0; color: #6b7280; font-size: 0.95rem; }
.filters { display: flex; gap: 6px; margin-top: 16px; }
.filters button { background: #e5e7eb; color: #1f2937; padding: 6px 12px; font-size: 0.9rem; }
.filters button.active { background: #4f46e5; color: #ffffff; }
li.empty { color: #6b7280; justify-content: center; }
.clear { background: transparent; color: #4f46e5; padding: 4px 0; font-size: 0.95rem; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active">All</button>
<button type="button" data-filter="pending">Pending</button>
<button type="button" data-filter="done">Done</button>
</div>
<ul id="task-list"></ul>
<p class="footer"><span id="count"></span> · <button type="button" class="clear" id="clear-done">Clear completed</button></p>
</div>
<script>
const STORAGE_KEY = 'study-planner-tasks';
function loadTasks() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return [];
try {
return JSON.parse(saved);
} catch {
return [];
}
}
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function update() {
saveTasks();
render();
}
let tasks = loadTasks();
let currentFilter = 'all';
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');
function visibleTasks() {
if (currentFilter === 'pending') return tasks.filter((t) => !t.done);
if (currentFilter === 'done') return tasks.filter((t) => t.done);
return tasks;
}
function render() {
list.innerHTML = '';
const shown = visibleTasks();
if (shown.length === 0) {
const li = document.createElement('li');
li.className = 'empty';
li.textContent = 'Nothing here. Enjoy a short break!';
list.append(li);
}
for (const task of shown) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
const left = tasks.filter((t) => !t.done).length;
count.textContent = left === 1 ? '1 task left' : `${left} tasks left`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
update();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
update();
});
filters.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
currentFilter = button.dataset.filter;
for (const b of filters.querySelectorAll('button')) {
b.classList.toggle('active', b === button);
}
render();
});
// TODO 1: select the #clear-done button.
// TODO 2: on click, keep only the tasks that are not done, then call update().
render();
</script>
</body>
</html>
Show one possible answer (try it yourself first!)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study Planner</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6fb; color: #1f2937; margin: 0; padding: 24px 16px; }
.planner { max-width: 440px; margin: 0 auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.sub { margin: 0 0 16px; color: #6b7280; }
form { display: flex; gap: 8px; }
form input { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem; }
button { padding: 10px 14px; border: none; border-radius: 8px; background: #4f46e5; color: #ffffff; font-size: 1rem; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 16px 0 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
li span { flex: 1; }
li.done span { text-decoration: line-through; color: #9ca3af; }
li input { width: 18px; height: 18px; }
.delete { background: #fee2e2; color: #b91c1c; padding: 6px 10px; font-size: 0.9rem; }
.footer { margin: 12px 0 0; color: #6b7280; font-size: 0.95rem; }
.filters { display: flex; gap: 6px; margin-top: 16px; }
.filters button { background: #e5e7eb; color: #1f2937; padding: 6px 12px; font-size: 0.9rem; }
.filters button.active { background: #4f46e5; color: #ffffff; }
li.empty { color: #6b7280; justify-content: center; }
.clear { background: transparent; color: #4f46e5; padding: 4px 0; font-size: 0.95rem; }
</style>
</head>
<body>
<div class="planner">
<h1>My Study Planner</h1>
<p class="sub">Semester exams are coming. One task at a time.</p>
<form id="task-form">
<input id="task-input" type="text" placeholder="For example: Revise Physics chapter 3" aria-label="New study task">
<button type="submit">Add</button>
</form>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active">All</button>
<button type="button" data-filter="pending">Pending</button>
<button type="button" data-filter="done">Done</button>
</div>
<ul id="task-list"></ul>
<p class="footer"><span id="count"></span> · <button type="button" class="clear" id="clear-done">Clear completed</button></p>
</div>
<script>
const STORAGE_KEY = 'study-planner-tasks';
function loadTasks() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return [];
try {
return JSON.parse(saved);
} catch {
return [];
}
}
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function update() {
saveTasks();
render();
}
let tasks = loadTasks();
let currentFilter = 'all';
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-input');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');
function visibleTasks() {
if (currentFilter === 'pending') return tasks.filter((t) => !t.done);
if (currentFilter === 'done') return tasks.filter((t) => t.done);
return tasks;
}
function render() {
list.innerHTML = '';
const shown = visibleTasks();
if (shown.length === 0) {
const li = document.createElement('li');
li.className = 'empty';
li.textContent = 'Nothing here. Enjoy a short break!';
list.append(li);
}
for (const task of shown) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'toggle';
check.checked = task.done;
check.setAttribute('aria-label', 'Mark as done');
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.type = 'button';
del.className = 'delete';
del.textContent = 'Delete';
li.append(check, span, del);
list.append(li);
}
const left = tasks.filter((t) => !t.done).length;
count.textContent = left === 1 ? '1 task left' : `${left} tasks left`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === '') {
input.focus();
return;
}
tasks.push({ id: Date.now(), text: text, done: false });
input.value = '';
input.focus();
update();
});
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
if (event.target.classList.contains('toggle')) {
const task = tasks.find((t) => t.id === id);
task.done = !task.done;
} else if (event.target.classList.contains('delete')) {
tasks = tasks.filter((t) => t.id !== id);
} else {
return;
}
update();
});
filters.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
currentFilter = button.dataset.filter;
for (const b of filters.querySelectorAll('button')) {
b.classList.toggle('active', b === button);
}
render();
});
const clearButton = document.querySelector('#clear-done');
clearButton.addEventListener('click', () => {
tasks = tasks.filter((t) => !t.done);
update();
});
render();
</script>
</body>
</html>
Key Takeaways
- Keep your data in an array of objects and treat the page as a picture of that array: change the data, then call
render(). - Listen for
submiton the form, callpreventDefault(), and usetrim()to ignore empty input. - Event delegation means one listener on a parent handles clicks on children, even children added later. Use
closest()anddata-attributes to find what was clicked. filterboth removes items (delete) and selects items (pending or done) without changing the original logic.localStoragestores only strings, so save withJSON.stringifyand load withJSON.parse.- Use
textContent, notinnerHTML, for text the user typed.