Skip to content

JavaScript Tutorial Practical Projects

Project 15: Final Mini Project, a Reading Tracker

Lesson 254 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

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.

index.html
HTML
<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

PartYou learned it in
State as an array of objects, render()To-Do project
Validation with messages and aria-invalidCustom Validation
filter, map, immutable updatesArrays, Immutability
Event delegationEvent Delegation
localStorage with safe JSONJSON with localStorage
Safe output with textContentSafe Output
Accessible controls and status messagesAccessibility chapter

Where to go from here

  1. Split it into modules (storage.js, render.js, main.js) following Project Structure.
  2. Add editing, sorting, a reading goal and a notes field per book.
  3. Fetch book information from an API with a proper loading and error state (Fetch).
  4. Add tests for the pure parts: a function that computes the percentage, one that filters books.
  5. 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
script.js
JS
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().