This project combines almost everything: forms and validation, state, rendering, event delegation, filtering, storage, accessibility and safe output. It is a small reading tracker where you add books, update their status and see progress.
<style>
.tracker { font-family: system-ui, sans-serif; max-width: 400px; }
form { display: grid; gap: 6px; margin-bottom: 10px; }
input, select, button { font: inherit; padding: 7px; }
button { cursor: pointer; }
.error { color: #b00020; font-size: 14px; margin: 0; min-height: 1.2em; }
.filters { display: flex; gap: 6px; margin: 8px 0; }
.filters button[aria-pressed="true"] { background: #1a5276; color: #fff; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #eee; padding: 6px 0; }
li .title { flex: 1; }
li.finished .title { text-decoration: line-through; color: #777; }
progress { width: 100%; }
</style>
<div class="tracker">
<form id="form" novalidate>
<label for="title">Book title</label>
<input id="title" aria-describedby="title-error" autocomplete="off">
<p class="error" id="title-error"></p>
<label for="pages">Pages</label>
<input id="pages" type="number" min="1" aria-describedby="pages-error">
<p class="error" id="pages-error"></p>
<button>Add book</button>
</form>
<div class="filters" role="group" aria-label="Show">
<button data-filter="all" aria-pressed="true">All</button>
<button data-filter="reading" aria-pressed="false">Reading</button>
<button data-filter="finished" aria-pressed="false">Finished</button>
</div>
<label for="progress">Finished</label>
<progress id="progress" max="100" value="0"></progress>
<p id="summary" role="status"></p>
<ul id="list"></ul>
</div>
<script>
const KEY = "reading-tracker:v1";
const load = () => {
try { const data = JSON.parse(localStorage.getItem(KEY)); return Array.isArray(data) ? data : null; } catch { return null; }
};
const save = () => { try { localStorage.setItem(KEY, JSON.stringify(books)); } catch { /* storage unavailable */ } };
let books = load() ?? [
{ id: 1, title: "The Slow Garden", pages: 312, status: "reading" },
{ id: 2, title: "Night Trains", pages: 268, status: "finished" }
];
let filter = "all";
const list = document.querySelector("#list");
function render() {
const visible = books.filter(b => filter === "all" || b.status === filter);
list.replaceChildren(...visible.map(book => {
const li = document.createElement("li");
li.dataset.id = book.id;
li.classList.toggle("finished", book.status === "finished");
const title = document.createElement("span");
title.className = "title";
title.textContent = `${book.title} (${book.pages} pages)`;
const toggle = document.createElement("button");
toggle.dataset.action = "toggle";
toggle.textContent = book.status === "finished" ? "Mark as reading" : "Mark as finished";
const del = document.createElement("button");
del.dataset.action = "delete";
del.textContent = "Remove";
del.setAttribute("aria-label", "Remove " + book.title);
li.append(title, toggle, del);
return li;
}));
const finished = books.filter(b => b.status === "finished").length;
const percent = books.length ? Math.round((finished / books.length) * 100) : 0;
document.querySelector("#progress").value = percent;
document.querySelector("#summary").textContent = visible.length === 0
? "No books to show."
: `${finished} of ${books.length} books finished (${percent}%)`;
}
// ---- adding a book, with validation ----
const form = document.querySelector("#form");
form.addEventListener("submit", event => {
event.preventDefault();
const title = form.elements.title.value.trim();
const pages = Number(form.elements.pages.value);
const titleError = title.length < 2 ? "Enter the book title." : "";
const pagesError = !Number.isInteger(pages) || pages < 1 ? "Enter the number of pages (a whole number)." : "";
document.querySelector("#title-error").textContent = titleError;
document.querySelector("#pages-error").textContent = pagesError;
form.elements.title.setAttribute("aria-invalid", String(titleError !== ""));
form.elements.pages.setAttribute("aria-invalid", String(pagesError !== ""));
if (titleError || pagesError) {
(titleError ? form.elements.title : form.elements.pages).focus();
return;
}
books = [...books, { id: Date.now(), title, pages, status: "reading" }];
save();
form.reset();
render();
});
// ---- row actions (event delegation) ----
list.addEventListener("click", event => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const id = Number(button.closest("li").dataset.id);
if (button.dataset.action === "delete") books = books.filter(b => b.id !== id);
else books = books.map(b => (b.id === id ? { ...b, status: b.status === "finished" ? "reading" : "finished" } : b));
save();
render();
});
// ---- filter buttons ----
document.querySelector(".filters").addEventListener("click", event => {
const button = event.target.closest("button[data-filter]");
if (!button) return;
filter = button.dataset.filter;
document.querySelectorAll(".filters button").forEach(b => b.setAttribute("aria-pressed", String(b === button)));
render();
});
render();
</script>
Skills used, and where they came from
| Part | You learned it in |
|---|---|
State as an array of objects, render() | To-Do project |
Validation with messages and aria-invalid | Custom Validation |
filter, map, immutable updates | Arrays, Immutability |
| Event delegation | Event Delegation |
localStorage with safe JSON | JSON with localStorage |
Safe output with textContent | Safe Output |
| Accessible controls and status messages | Accessibility chapter |
Where to go from here
- Split it into modules (
storage.js,render.js,main.js) following Project Structure. - Add editing, sorting, a reading goal and a notes field per book.
- Fetch book information from an API with a proper loading and error state (Fetch).
- Add tests for the pure parts: a function that computes the percentage, one that filters books.
- Make a copy of the project in a new folder, and rebuild it from memory. That is the best way to find what you still need to practise.
Exercise: add a sort menu
Add a <select> that sorts the visible books by title (A to Z), pages (high to low) or newest first (using id).
Show a possible solution
const sorters = {
title: (a, b) => a.title.localeCompare(b.title),
pages: (a, b) => b.pages - a.pages,
newest: (a, b) => b.id - a.id
};
let sort = "newest";
// inside render(), after filtering:
const visible = books
.filter(b => filter === "all" || b.status === filter)
.sort(sorters[sort]); // filter() returned a new array, so sorting it is safe
Add a <select id="sort"> and a change listener that sets sort and calls render().