Reading Tracker with TypeScript and Vite

Build a browser-based reading tracker with TypeScript modules and localStorage.

Introduction

30 Second Summary

Everyone has a growing stack of books they want to read, are currently reading, or have already finished. Keeping track of where each book stands is a small organizational challenge with a satisfying solution.

In this project, you will build a browser-based reading tracker using TypeScript and Vite. Books move through three lifecycle states, each carrying only the data relevant to that stage.

What You'll Build

A multi-file TypeScript application that runs in the browser, where books transition through want-to-read, reading, and completed states with persistent storage.

By the end of this project, you'll have:

  • A TypeScript type system using discriminated unions to model book states where each state carries only its relevant data.
  • A working browser app that adds books, transitions them through states, and persists data across page reloads using localStorage.
  • Filter and sort controls that use array methods with type narrowing to safely access state-specific fields.
  • Secret Mission: Export your completed books as a downloadable JSON file sorted by rating.

Are there any prerequisites?

This project builds on Projects 1 and 2 in the series, where you learned TypeScript interfaces, discriminated unions, state transitions, and CLI routing. You should also have Node.js and npm already installed from those projects.

Set Up the Vite Project

To build a browser app, you need a development environment that can serve your TypeScript files to a browser in real time.

Vite is a build tool that does exactly this. It watches your files for changes, compiles your TypeScript instantly, and refreshes the browser automatically. In this step, you will scaffold a Vite project, verify it works, then clean out the demo content so you have a blank canvas.

In this step, get ready to:
  • Scaffold a new Vite + TypeScript project and install its dependencies.
  • Start the dev server and verify the default page loads in your browser.
  • Clean out the demo files so you have a blank project ready for your own code.
Scaffold the Vite project

Vite provides a scaffolding command that creates a ready-to-go project folder with TypeScript configured. You will run it from your Desktop so the project folder is easy to find.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Vite project, move into the new folder, and install dependencies by running these commands:
npm create vite@latest reading-tracker -- --template vanilla-ts
cd reading-tracker
npm install

What do these commands do?

  • npm create vite@latest reading-tracker -- --template vanilla-ts creates a new folder called reading-tracker with a pre-configured TypeScript project inside. The vanilla-ts template gives you plain TypeScript without a framework like React or Vue.
  • cd reading-tracker moves your terminal into the new folder.
  • npm install downloads the packages Vite needs to run your dev server and compile TypeScript.
  • Confirm the project was created by running:
ls
  • You should see index.html, package.json, tsconfig.json, a src/ folder, and a node_modules/ folder in the output.

Command not found or permission error?

  • Make sure you have Node.js 20.19+ or 22.12+ installed. Run node --version to check.
  • If npm create asks for permission to install create-vite, type y and press Enter to confirm.

Help me troubleshoot my Vite scaffold command. I ran npm create vite@latest reading-tracker -- --template vanilla-ts and got an error.

Start the dev server

Vite includes a development server that serves your app locally and automatically refreshes when you change a file. This means you can see your changes instantly without manually reloading.

  • Open the reading-tracker folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the reading-tracker folder, and click Open.
  • Open a new integrated terminal with Ctrl+` (backtick).
  • Start the dev server by running:
npm run dev
  • Your terminal displays a local URL (usually http://localhost:5173). Hold Cmd (macOS) or Ctrl (Windows) and click the URL to open it in your browser.
  • You should see the default Vite + TypeScript page with the Vite and TypeScript logos and a counter button.

What files did Vite create?

Take a look at the file tree in the VS Code sidebar. Here are the key files:

  • index.html is the HTML shell that loads your TypeScript via a <script type="module"> tag.
  • src/main.ts is the entry point where your app code starts running.
  • src/style.css is the stylesheet imported by main.ts.
  • package.json lists your project dependencies and scripts like npm run dev.
  • tsconfig.json configures how TypeScript compiles your code.

Page not loading?

  • Check that the terminal still shows the dev server running. If you see your normal prompt instead, run npm run dev again.
  • Try opening http://localhost:5173 manually in your browser if clicking the link did not work.
  • Make sure no other process is using port 5173. Vite will pick a different port if 5173 is taken and display it in the terminal output.

Help me get my Vite dev server running. I ran npm run dev but the page is not loading in my browser.

Clean the default files

The scaffold includes demo content (a counter app, logos, and default styles) that you do not need. Cleaning these out gives you a blank slate for your reading tracker.

  • Delete the demo files by running these commands in your terminal (open a second terminal with Ctrl+Shift+` so the dev server keeps running):
rm src/counter.ts
rm src/typescript.svg
rm public/vite.svg

What are these files?

  • src/counter.ts is the demo counter logic that powered the button on the default page.
  • src/typescript.svg and public/vite.svg are the logo images displayed on the default page.
  • Open src/main.ts in the VS Code sidebar. Select all the content (Cmd+A on macOS, Ctrl+A on Windows) and delete it. Leave the file completely empty.
  • Open src/style.css in the sidebar. Select all and delete. Leave it completely empty.
  • Open index.html in the sidebar. Select all and replace the content with this:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Reading Tracker</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What changed in index.html?

You removed the favicon link (which pointed to the vite.svg file you just deleted) and changed the page title from "Vite + TS" to "Reading Tracker". The <div id="app"> and <script> tag stay because that is how Vite loads your TypeScript entry point.

  • Save all three files (src/main.ts, src/style.css, index.html).
  • Switch to your browser. The page should now be completely blank (white page, no logos, no counter).
  • Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and confirm there are no red error messages.

Still seeing the old page or console errors?

  • If the page still shows logos, do a hard refresh (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows) to clear cached assets.
  • If you see a 404 error in the console for a .svg file, check that you deleted both public/vite.svg and src/typescript.svg, and that index.html no longer has a <link rel="icon"> line.
  • Make sure src/main.ts is truly empty. If it still imports counter.ts, the deleted file causes an error.

Help me fix console errors after cleaning my Vite project files.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files (index.html, src/main.ts, src/style.css) and your browser shows a blank white page with no console errors.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point:

index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Reading Tracker</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

src/main.ts and src/style.css should both be completely empty (no content at all).

Your blank canvas is ready. Next up, you will build the data layer that defines your book types, storage functions, and state transitions.

Build the Data Layer

Your project skeleton is ready. Now it's time to define the data structures that power your reading tracker.

Every book moves through three lifecycle states: want-to-read, reading, and completed. You'll create three modules that define these types, persist data to the browser, and enforce valid state transitions.

In this step, get ready to:
  • Define the book type system with discriminated unions.
  • Build a storage module that reads and writes to localStorage.
  • Create transition functions that move books between states.
Define the book type system

Your reading tracker needs a type system that captures what data belongs to each book state. A book on your wishlist only needs a title and author. A book you're reading also needs a start date. A completed book adds a finish date, rating, and summary.

This is the same discriminated unions pattern from your previous projects. Each state gets a literal status field that TypeScript uses to narrow the type.

  • Create a new file called src/types.ts in your editor, then paste the following code:
// Shared fields every book has regardless of state
type BookBase = {
  id: string;
  title: string;
  author: string;
  addedAt: string;
};

// A book the user wants to read (no extra data needed)
type WantToRead = BookBase & {
  status: "want-to-read";
};

// A book currently being read (tracks when reading started)
type Reading = BookBase & {
  status: "reading";
  startedAt: string;
};

// A finished book (tracks dates, rating, and summary)
type Completed = BookBase & {
  status: "completed";
  startedAt: string;
  completedAt: string;
  rating: number;
  summary: string;
};

// The discriminated union: a Book is exactly one of the three states
type Book = WantToRead | Reading | Completed;

export type { Book, WantToRead, Reading, Completed };

What does this code do?

BookBase defines the fields every book shares: an ID, title, author, and the date it was added.

Each state type (WantToRead, Reading, Completed) extends BookBase with a literal status string and any state-specific data. TypeScript uses that literal status field to narrow the union in switch statements later.

The Book union at the bottom is what the rest of your app works with. A variable typed as Book could be any of the three states.

  • Save src/types.ts.

You won't see a change in the browser yet. The next file imports these types, which confirms they resolve correctly.

Seeing red underlines in types.ts?

Make sure each type definition uses type (lowercase), not Type or interface. Check that the & intersection operator sits between BookBase and the object with status.

Help me fix red underlines in my types.ts file

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

type BookBase = {
  id: string;
  title: string;
  author: string;
  addedAt: string;
};

type WantToRead = BookBase & {
  status: "want-to-read";
};

type Reading = BookBase & {
  status: "reading";
  startedAt: string;
};

type Completed = BookBase & {
  status: "completed";
  startedAt: string;
  completedAt: string;
  rating: number;
  summary: string;
};

type Book = WantToRead | Reading | Completed;

export type { Book, WantToRead, Reading, Completed };
Create the storage module

Your tracker needs to remember books between page reloads. localStorage is the browser equivalent of the file I/O you used in Project 1. It stores key-value pairs as strings that persist even after you close the tab.

  • Create a new file called src/storage.ts and paste the following code:
import type { Book } from "./types";

// Key used to store and retrieve books from the browser's localStorage
const STORAGE_KEY = "reading-tracker-books";

// Load all books from localStorage (returns empty array if nothing saved)
function loadBooks(): Book[] {
  const data = localStorage.getItem(STORAGE_KEY);
  if (!data) return [];
  return JSON.parse(data);
}

// Save the full books array to localStorage as a JSON string
function saveBooks(books: Book[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(books));
}

export { loadBooks, saveBooks };

What does this code do?

loadBooks() reads the JSON string from localStorage and parses it back into a typed array. If nothing is stored yet, it returns an empty array.

saveBooks() takes the current books array, converts it to a JSON string, and writes it back. This is the same serialize/deserialize pattern as JSON.stringify() and JSON.parse() you used with file I/O.

  • Save src/storage.ts.
  • Check your editor for red underlines. The import type { Book } from "./types" line resolving cleanly confirms both files are connected.

Red underline on the import?

Make sure src/types.ts is saved. Unsaved files aren't visible to TypeScript's module resolution. Also check that the import path is ./types (with the dot-slash prefix), not just types.

Help me fix my import in storage.ts

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

import type { Book } from "./types";

const STORAGE_KEY = "reading-tracker-books";

function loadBooks(): Book[] {
  const data = localStorage.getItem(STORAGE_KEY);
  if (!data) return [];
  return JSON.parse(data);
}

function saveBooks(books: Book[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(books));
}

export { loadBooks, saveBooks };
Create the transition functions

State transitions are the rules that govern how a book moves from one state to the next. A want-to-read book can become a reading book. A reading book can become a completed book. These functions enforce that only valid transitions happen.

  • Create a new file called src/transitions.ts and paste the following code:
import type { WantToRead, Reading, Completed } from "./types";

// Transition a want-to-read book into the reading state
function startReading(book: WantToRead): Reading {
  return {
    ...book,
    status: "reading",
    startedAt: new Date().toISOString(),
  };
}

// Transition a reading book into the completed state with a rating and summary
function completeBook(book: Reading, rating: number, summary: string): Completed {
  return {
    ...book,
    status: "completed",
    completedAt: new Date().toISOString(),
    rating,
    summary,
  };
}

export { startReading, completeBook };

What does this code do?

startReading() accepts only a WantToRead book. It spreads all existing properties, overwrites the status to "reading", and adds a startedAt timestamp.

completeBook() accepts only a Reading book. TypeScript enforces that you cannot skip states. You can't complete a book you haven't started reading.

  • Save src/transitions.ts.
  • Check your editor for red underlines. All three imports from ./types should resolve cleanly.

To confirm all three modules work together, temporarily test them in src/main.ts. Paste this test code:

import type { WantToRead } from "./types";
import { startReading } from "./transitions";

const testBook: WantToRead = {
  id: "test-1",
  title: "Test Book",
  author: "Test Author",
  addedAt: new Date().toISOString(),
  status: "want-to-read",
};

console.log("Original:", testBook);
console.log("After transition:", startReading(testBook));
  • Save src/main.ts and switch to your browser.
  • Open the browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • You should see two logged objects. The second one has status: "reading" and a startedAt timestamp.
  • Once confirmed, delete all the test code from src/main.ts so it's empty again. Save the file.

Don't see the logged objects?

Make sure all three files (types.ts, storage.ts, transitions.ts) are saved. Check that your dev server is still running in the terminal. If you see an error in the console, the message usually points to the file and line number with the issue.

Help me debug my data layer modules

✔️ Awesome, I've got everything!

Great. Make sure you've cleared src/main.ts back to empty and saved it.

ⓧ I'd like to double check the full code

import type { WantToRead, Reading, Completed } from "./types";

function startReading(book: WantToRead): Reading {
  return {
    ...book,
    status: "reading",
    startedAt: new Date().toISOString(),
  };
}

function completeBook(book: Reading, rating: number, summary: string): Completed {
  return {
    ...book,
    status: "completed",
    completedAt: new Date().toISOString(),
    rating,
    summary,
  };
}

export { startReading, completeBook };

Your data layer is complete. You have types that define what a book looks like in each state, a storage module that persists data between reloads, and transition functions that enforce valid state changes. Next up, you'll build the HTML and CSS that give your tracker a visual interface.

Build the UI Structure

Your data layer is complete. You have types, storage, and transitions ready to power a reading tracker. Now you need something the user can actually see and interact with.

In this step, you'll build the visual shell of your app. That means HTML for the page structure (form, dropdowns, book list area) and CSS to make it look polished.

In this step, get ready to:
  • Create the HTML structure with a form, filter controls, and book list container.
  • Write a complete stylesheet with layout, form, card, and button styles.
Create the HTML app shell

The HTML defines everything the user can interact with. Your reading tracker needs three main areas: a form to add new books, controls to filter and sort, and a container where book cards will render.

  • In your editor, open index.html (in the root of your reading-tracker folder).
  • Replace the entire file content with the following code:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Reading Tracker</title>
  </head>
  <body>
    <div id="app">
      <h1>Reading Tracker</h1>
      <!-- Form to add new books -->
      <form id="add-book-form">
        <input type="text" id="title-input" placeholder="Book title" required />
        <input type="text" id="author-input" placeholder="Author" required />
        <button type="submit">Add Book</button>
      </form>
      <!-- Filter and sort controls -->
      <div id="controls">
        <select id="filter-select">
          <option value="all">All Books</option>
          <option value="want-to-read">Want to Read</option>
          <option value="reading">Reading</option>
          <option value="completed">Completed</option>
        </select>
        <select id="sort-select">
          <option value="added">Date Added</option>
          <option value="rating-desc">Rating: High to Low</option>
          <option value="rating-asc">Rating: Low to High</option>
        </select>
      </div>
      <!-- Book cards render here -->
      <div id="book-list"></div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What does this code do?

  • The #add-book-form has two text inputs (title and author) plus a submit button. The required attribute prevents empty submissions.
  • The #filter-select dropdown maps directly to your three book statuses from types.ts (plus an "all" option).
  • The #sort-select offers three sort orders: date added (default), rating high-to-low, and rating low-to-high.
  • The empty #book-list div is where your rendering module will inject book cards later.
  • Save index.html.
  • Refresh your browser at http://localhost:5173. You should see the "Reading Tracker" heading, two text inputs, an "Add Book" button, and two dropdown menus.

Don't see the form elements?

  • Make sure you kept the <script type="module" src="/src/main.ts"></script> tag inside the <body>. Vite needs this to connect to the dev server.
  • Check that all your HTML tags are properly closed. A missing </select> or </div> can break the entire page layout.

Why aren't my HTML form elements showing up in the browser?

✔️ Awesome, I've got everything!

Great. Make sure you've saved index.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Reading Tracker</title>
  </head>
  <body>
    <div id="app">
      <h1>Reading Tracker</h1>
      <form id="add-book-form">
        <input type="text" id="title-input" placeholder="Book title" required />
        <input type="text" id="author-input" placeholder="Author" required />
        <button type="submit">Add Book</button>
      </form>
      <div id="controls">
        <select id="filter-select">
          <option value="all">All Books</option>
          <option value="want-to-read">Want to Read</option>
          <option value="reading">Reading</option>
          <option value="completed">Completed</option>
        </select>
        <select id="sort-select">
          <option value="added">Date Added</option>
          <option value="rating-desc">Rating: High to Low</option>
          <option value="rating-asc">Rating: Low to High</option>
        </select>
      </div>
      <div id="book-list"></div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
Write the stylesheet

With the HTML structure in place, you need CSS to turn those raw elements into a polished interface. The stylesheet defines everything from page layout to the colored left-borders that will distinguish each book state.

  • Open src/style.css in your editor.
  • Add the page layout, form, and controls styles by pasting the following code:
/* Reset default browser spacing */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Base page styles */
body {
  font-family: system-ui, -apple-system, sans-serif;
  background-color: #f5f5f5;
  color: #333;
  line-height: 1.6;
  padding: 2rem;
}

/* Center the app container */
#app {
  max-width: 700px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 1.5rem;
  color: #1a1a1a;
}

/* Horizontal form layout */
#add-book-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

#add-book-form input {
  flex: 1;
  padding: 0.5rem 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.9rem;
}

#add-book-form button {
  padding: 0.5rem 1rem;
  background-color: #2563eb;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
}

#add-book-form button:hover {
  background-color: #1d4ed8;
}

/* Filter and sort row */
#controls {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}

#controls select {
  padding: 0.4rem 0.6rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.85rem;
  background-color: white;
}

What does this code do?

  • The * reset removes default browser margins and padding so spacing is consistent across all browsers.
  • The #app container is capped at 700px and centered with margin: 0 auto, keeping the content readable on wide screens.
  • The form uses display: flex to lay out the inputs and button in a single horizontal row.
  • Now add the book card and component styles below the controls styles you just pasted:
/* Book card base styles */
.book-card {
  background: white;
  border-radius: 8px;
  padding: 1rem 1.25rem;
  margin-bottom: 0.75rem;
  border-left: 4px solid #ddd;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

/* State-specific border colors */
.book-card.want-to-read {
  border-left-color: #f59e0b;
}

.book-card.reading {
  border-left-color: #2563eb;
}

.book-card.completed {
  border-left-color: #16a34a;
}

.book-card h3 {
  font-size: 1.1rem;
  margin-bottom: 0.25rem;
}

.author {
  color: #666;
  font-size: 0.85rem;
  margin-bottom: 0.5rem;
}

/* Small pill-shaped status label */
.status-badge {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 12px;
  font-size: 0.75rem;
  font-weight: 500;
  background-color: #f0f0f0;
  color: #555;
  margin-bottom: 0.5rem;
}

.meta {
  font-size: 0.85rem;
  color: #666;
  margin-bottom: 0.5rem;
}

.summary {
  font-size: 0.85rem;
  color: #444;
  font-style: italic;
  margin-top: 0.25rem;
}

/* Action buttons on cards */
.start-btn,
.complete-btn {
  padding: 0.35rem 0.75rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  margin-top: 0.5rem;
}

.start-btn {
  background-color: #2563eb;
  color: white;
}

.start-btn:hover {
  background-color: #1d4ed8;
}

.complete-btn {
  background-color: #16a34a;
  color: white;
}

.complete-btn:hover {
  background-color: #15803d;
}

.empty-message {
  text-align: center;
  color: #999;
  padding: 2rem;
}

What does this code do?

  • Each book card gets a colored left border based on its state: amber (#f59e0b) for want-to-read, blue (#2563eb) for reading, and green (#16a34a) for completed.
  • The .status-badge uses a pill shape (border-radius: 12px) to visually label each book's current state.
  • The .start-btn and .complete-btn styles match the state border colors so the action buttons feel connected to the transition they trigger.
  • Save src/style.css.

You won't see the custom styles applied yet. In the next step, you'll import this stylesheet in main.ts, which tells Vite to load it into the page.

Seeing a CSS syntax error in the terminal?

  • Check for missing semicolons at the end of property lines. Every CSS property must end with a semicolon.
  • Make sure every opening { has a matching closing }. A mismatched brace breaks everything below it.

Why am I getting a CSS syntax error in my Vite project?

✔️ Awesome, I've got everything!

Great. Double check you've saved src/style.css before continuing.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
  background-color: #f5f5f5;
  color: #333;
  line-height: 1.6;
  padding: 2rem;
}

#app {
  max-width: 700px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 1.5rem;
  color: #1a1a1a;
}

#add-book-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

#add-book-form input {
  flex: 1;
  padding: 0.5rem 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.9rem;
}

#add-book-form button {
  padding: 0.5rem 1rem;
  background-color: #2563eb;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
}

#add-book-form button:hover {
  background-color: #1d4ed8;
}

#controls {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}

#controls select {
  padding: 0.4rem 0.6rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.85rem;
  background-color: white;
}

.book-card {
  background: white;
  border-radius: 8px;
  padding: 1rem 1.25rem;
  margin-bottom: 0.75rem;
  border-left: 4px solid #ddd;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.book-card.want-to-read {
  border-left-color: #f59e0b;
}

.book-card.reading {
  border-left-color: #2563eb;
}

.book-card.completed {
  border-left-color: #16a34a;
}

.book-card h3 {
  font-size: 1.1rem;
  margin-bottom: 0.25rem;
}

.author {
  color: #666;
  font-size: 0.85rem;
  margin-bottom: 0.5rem;
}

.status-badge {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 12px;
  font-size: 0.75rem;
  font-weight: 500;
  background-color: #f0f0f0;
  color: #555;
  margin-bottom: 0.5rem;
}

.meta {
  font-size: 0.85rem;
  color: #666;
  margin-bottom: 0.5rem;
}

.summary {
  font-size: 0.85rem;
  color: #444;
  font-style: italic;
  margin-top: 0.25rem;
}

.start-btn,
.complete-btn {
  padding: 0.35rem 0.75rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  margin-top: 0.5rem;
}

.start-btn {
  background-color: #2563eb;
  color: white;
}

.start-btn:hover {
  background-color: #1d4ed8;
}

.complete-btn {
  background-color: #16a34a;
  color: white;
}

.complete-btn:hover {
  background-color: #15803d;
}

.empty-message {
  text-align: center;
  color: #999;
  padding: 2rem;
}

Your HTML and CSS are ready. The page has structure and style waiting to be activated. Next up, you'll wire the data layer to the DOM so books actually appear on screen.

Connect Data to the DOM

You have a data layer that defines book types, persists them in localStorage, and transitions them between states. You also have a styled HTML shell waiting to display data.

The missing piece is the bridge between your data and what the user sees. In this step, you will build a rendering module that turns book objects into visible cards, then wire up main.ts to connect form inputs, state transitions, and the render cycle into a working application.

In this step, get ready to:
  • Create a rendering module that builds DOM cards from book objects.
  • Wire up main.ts with form handling, state transitions, and a refresh cycle.
  • Test the full lifecycle: adding books, starting reading, completing with a rating, and persisting across reloads.
Create the rendering module

The rendering module takes book data and turns it into HTML elements the browser can display. It uses the same switch pattern from Project 2 to render different content depending on each book's status.

  • Create a new file called src/render.ts in your editor.
  • Add the following code that defines the callback types and the main rendering function:
import type { Book } from "./types";

type RenderCallbacks = {
  onStartReading: (id: string) => void;
  onComplete: (id: string, rating: number, summary: string) => void;
};

function renderBooks(
  container: HTMLDivElement,
  books: Book[],
  callbacks: RenderCallbacks
): void {
  container.innerHTML = "";

  if (books.length === 0) {
    container.innerHTML = '<p class="empty-message">No books yet. Add one above!</p>';
    return;
  }

  books.forEach((book) => {
    const card = document.createElement("div");
    card.className = `book-card ${book.status}`;
    card.innerHTML = renderBookContent(book);
    attachCardListeners(card, book, callbacks);
    container.appendChild(card);
  });
}

function renderBookContent(book: Book): string {
  const header = `<h3>${book.title}</h3><p class="author">by ${book.author}</p>`;
  const badge = `<span class="status-badge">${formatStatus(book.status)}</span>`;

  switch (book.status) {
    case "want-to-read":
      return `${header}${badge}<button class="start-btn">Start Reading</button>`;
    case "reading":
      return `${header}${badge}<p class="meta">Started: ${new Date(book.startedAt).toLocaleDateString()}</p><button class="complete-btn">Mark Complete</button>`;
    case "completed":
      return `${header}${badge}<p class="meta">Rating: ${"★".repeat(book.rating)}${"☆".repeat(5 - book.rating)}</p><p class="summary">${book.summary}</p>`;
  }
}

function formatStatus(status: string): string {
  return status
    .split("-")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ");
}

What does this code do?

RenderCallbacks defines the shape of handler functions that main.ts will pass in. This keeps the render module decoupled from the state logic.

renderBooks clears the container, then loops through each book to create a card element. renderBookContent uses a switch on book.status to produce different HTML for each state. TypeScript narrows the type inside each case, so you can safely access book.startedAt or book.rating without errors.

  • Below the formatStatus function, add the interaction handlers that wire up button clicks:
function attachCardListeners(
  card: HTMLDivElement,
  book: Book,
  callbacks: RenderCallbacks
): void {
  if (book.status === "want-to-read") {
    const btn = card.querySelector(".start-btn");
    btn?.addEventListener("click", () => callbacks.onStartReading(book.id));
  }

  if (book.status === "reading") {
    const btn = card.querySelector(".complete-btn");
    btn?.addEventListener("click", () => {
      const rating = promptRating();
      const summary = prompt("Write a brief summary of the book:") || "";
      if (rating !== null) {
        callbacks.onComplete(book.id, rating, summary);
      }
    });
  }
}

function promptRating(): number | null {
  const input = prompt("Rate this book (1-5):");
  if (!input) return null;
  const rating = parseInt(input, 10);
  if (isNaN(rating) || rating < 1 || rating > 5) {
    alert("Please enter a number between 1 and 5.");
    return promptRating();
  }
  return rating;
}

export { renderBooks };

What does this code do?

attachCardListeners checks the book's status and attaches the correct button listener. Want-to-read cards get a "Start Reading" click handler. Reading cards get a "Mark Complete" handler that prompts for a rating and summary.

promptRating uses the browser's built-in prompt() to collect a number between 1 and 5. If the user enters something invalid, it calls itself recursively to ask again.

  • Save src/render.ts.
  • Check your terminal. The dev server should show no compilation errors for the new file.

Seeing a type error in render.ts?

  • Make sure your import path is ./types (with the dot-slash). A bare types will fail to resolve.
  • Verify that src/types.ts exports the Book type. The export line should read export type { Book, WantToRead, Reading, Completed };.

Help me debug a type error in my render.ts file.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import type { Book } from "./types";

type RenderCallbacks = {
  onStartReading: (id: string) => void;
  onComplete: (id: string, rating: number, summary: string) => void;
};

function renderBooks(
  container: HTMLDivElement,
  books: Book[],
  callbacks: RenderCallbacks
): void {
  container.innerHTML = "";

  if (books.length === 0) {
    container.innerHTML = '<p class="empty-message">No books yet. Add one above!</p>';
    return;
  }

  books.forEach((book) => {
    const card = document.createElement("div");
    card.className = `book-card ${book.status}`;
    card.innerHTML = renderBookContent(book);
    attachCardListeners(card, book, callbacks);
    container.appendChild(card);
  });
}

function renderBookContent(book: Book): string {
  const header = `<h3>${book.title}</h3><p class="author">by ${book.author}</p>`;
  const badge = `<span class="status-badge">${formatStatus(book.status)}</span>`;

  switch (book.status) {
    case "want-to-read":
      return `${header}${badge}<button class="start-btn">Start Reading</button>`;
    case "reading":
      return `${header}${badge}<p class="meta">Started: ${new Date(book.startedAt).toLocaleDateString()}</p><button class="complete-btn">Mark Complete</button>`;
    case "completed":
      return `${header}${badge}<p class="meta">Rating: ${"★".repeat(book.rating)}${"☆".repeat(5 - book.rating)}</p><p class="summary">${book.summary}</p>`;
  }
}

function formatStatus(status: string): string {
  return status
    .split("-")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ");
}

function attachCardListeners(
  card: HTMLDivElement,
  book: Book,
  callbacks: RenderCallbacks
): void {
  if (book.status === "want-to-read") {
    const btn = card.querySelector(".start-btn");
    btn?.addEventListener("click", () => callbacks.onStartReading(book.id));
  }

  if (book.status === "reading") {
    const btn = card.querySelector(".complete-btn");
    btn?.addEventListener("click", () => {
      const rating = promptRating();
      const summary = prompt("Write a brief summary of the book:") || "";
      if (rating !== null) {
        callbacks.onComplete(book.id, rating, summary);
      }
    });
  }
}

function promptRating(): number | null {
  const input = prompt("Rate this book (1-5):");
  if (!input) return null;
  const rating = parseInt(input, 10);
  if (isNaN(rating) || rating < 1 || rating > 5) {
    alert("Please enter a number between 1 and 5.");
    return promptRating();
  }
  return rating;
}

export { renderBooks };
Wire up the main entry point

The main module is the conductor. It imports every other module, grabs DOM elements, and defines the refresh cycle that keeps the screen in sync with your data.

  • Open src/main.ts (it should be empty from the cleanup in Step 1).
  • Add the imports, book data initialization, and DOM element queries:
import "./style.css";
import type { Book, WantToRead } from "./types";
import { loadBooks, saveBooks } from "./storage";
import { startReading, completeBook } from "./transitions";
import { renderBooks } from "./render";

// Load any previously saved books from localStorage
let books: Book[] = loadBooks();

// Grab references to the DOM elements we need to interact with
const form = document.querySelector<HTMLFormElement>("#add-book-form")!;
const titleInput = document.querySelector<HTMLInputElement>("#title-input")!;
const authorInput = document.querySelector<HTMLInputElement>("#author-input")!;
const filterSelect = document.querySelector<HTMLSelectElement>("#filter-select")!;
const sortSelect = document.querySelector<HTMLSelectElement>("#sort-select")!;
const bookList = document.querySelector<HTMLDivElement>("#book-list")!;

What does this code do?

import "./style.css" tells Vite to bundle the stylesheet with your app. The type imports pull in your Book types without generating runtime code.

Each querySelector call grabs a reference to an HTML element using its id. The ! (non-null assertion) tells TypeScript "this element definitely exists in the HTML." The generic type parameter (like <HTMLFormElement>) gives you type-safe access to element-specific properties.

  • Below the DOM queries, add the filtering logic and the refresh function that keeps the UI in sync:
// Filter and sort books based on the dropdown selections
function getFilteredAndSortedBooks(): Book[] {
  let filtered = books;
  const filterValue = filterSelect.value;

  if (filterValue !== "all") {
    filtered = books.filter((book) => book.status === filterValue);
  }

  const sortValue = sortSelect.value;

  if (sortValue === "rating-desc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingB - ratingA;
    });
  } else if (sortValue === "rating-asc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingA - ratingB;
    });
  }

  return filtered;
}

// Save current state and re-render the book list
function refresh(): void {
  saveBooks(books);
  renderBooks(bookList, getFilteredAndSortedBooks(), {
    onStartReading: handleStartReading,
    onComplete: handleComplete,
  });
}

What does this code do?

getFilteredAndSortedBooks reads the current dropdown values and applies .filter() and .sort() to the books array. Notice the type narrowing inside the sort: a.status === "completed" ? a.rating : 0 safely accesses the rating field only when TypeScript knows the book is completed.

refresh is the heartbeat of the app. Every time data changes, it saves to localStorage and re-renders the filtered list. This pattern keeps your UI and persistence layer always in sync.

  • Below the refresh function, add the transition handlers and event listeners:
// Handle transitioning a book from want-to-read to reading
function handleStartReading(id: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "want-to-read"
      ? startReading(book)
      : book
  );
  refresh();
}

// Handle transitioning a book from reading to completed
function handleComplete(id: string, rating: number, summary: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "reading"
      ? completeBook(book, rating, summary)
      : book
  );
  refresh();
}

// Listen for new book submissions
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const newBook: WantToRead = {
    id: crypto.randomUUID(),
    title: titleInput.value.trim(),
    author: authorInput.value.trim(),
    addedAt: new Date().toISOString(),
    status: "want-to-read",
  };
  books.push(newBook);
  titleInput.value = "";
  authorInput.value = "";
  refresh();
});

// Re-render when filter or sort changes
filterSelect.addEventListener("change", refresh);
sortSelect.addEventListener("change", refresh);

// Initial render on page load
refresh();

What does this code do?

handleStartReading and handleComplete use .map() to produce a new array where only the target book is transitioned. The status check (book.status === "want-to-read") narrows the type so TypeScript allows the transition function call.

The form listener creates a new WantToRead object using crypto.randomUUID() for a unique ID, pushes it into the array, clears the inputs, and calls refresh() to save and re-render.

  • Save src/main.ts.
  • Check the browser at http://localhost:5173. You should see the message "No books yet. Add one above!" in the book list area.

Seeing a blank page or console errors?

  • Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for import errors pointing at a wrong file path.
  • Make sure each import uses a relative path starting with ./ (e.g. ./storage, not storage).
  • Confirm that each module file exports the functions main.ts imports. For example, render.ts must have export { renderBooks }; at the bottom.

Help me debug my main.ts imports and rendering.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import "./style.css";
import type { Book, WantToRead } from "./types";
import { loadBooks, saveBooks } from "./storage";
import { startReading, completeBook } from "./transitions";
import { renderBooks } from "./render";

let books: Book[] = loadBooks();

const form = document.querySelector<HTMLFormElement>("#add-book-form")!;
const titleInput = document.querySelector<HTMLInputElement>("#title-input")!;
const authorInput = document.querySelector<HTMLInputElement>("#author-input")!;
const filterSelect = document.querySelector<HTMLSelectElement>("#filter-select")!;
const sortSelect = document.querySelector<HTMLSelectElement>("#sort-select")!;
const bookList = document.querySelector<HTMLDivElement>("#book-list")!;

function getFilteredAndSortedBooks(): Book[] {
  let filtered = books;
  const filterValue = filterSelect.value;

  if (filterValue !== "all") {
    filtered = books.filter((book) => book.status === filterValue);
  }

  const sortValue = sortSelect.value;

  if (sortValue === "rating-desc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingB - ratingA;
    });
  } else if (sortValue === "rating-asc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingA - ratingB;
    });
  }

  return filtered;
}

function refresh(): void {
  saveBooks(books);
  renderBooks(bookList, getFilteredAndSortedBooks(), {
    onStartReading: handleStartReading,
    onComplete: handleComplete,
  });
}

function handleStartReading(id: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "want-to-read"
      ? startReading(book)
      : book
  );
  refresh();
}

function handleComplete(id: string, rating: number, summary: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "reading"
      ? completeBook(book, rating, summary)
      : book
  );
  refresh();
}

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const newBook: WantToRead = {
    id: crypto.randomUUID(),
    title: titleInput.value.trim(),
    author: authorInput.value.trim(),
    addedAt: new Date().toISOString(),
    status: "want-to-read",
  };
  books.push(newBook);
  titleInput.value = "";
  authorInput.value = "";
  refresh();
});

filterSelect.addEventListener("change", refresh);
sortSelect.addEventListener("change", refresh);

refresh();
Test the full book lifecycle

Your data layer and rendering module are connected. Time to walk a book through every state transition and confirm persistence works.

  • In the browser, type a book title (e.g. The Hobbit) into the first input and an author (e.g. J.R.R. Tolkien) into the second.
  • Click Add Book.
  • You should see a card appear with an amber left border, a "Want To Read" badge, and a "Start Reading" button.
  • Click Start Reading.
  • The card should change to a blue left border with a "Reading" badge, a start date, and a "Mark Complete" button.
  • Click Mark Complete. A prompt asks you to rate the book (enter a number between 1 and 5). A second prompt asks for a summary.
  • The card should now show a green left border, a "Completed" badge, star rating, and your summary text.
  • Refresh the browser page (Cmd+R on macOS or Ctrl+R on Windows). Your book should still be there with the same state. This confirms localStorage persistence is working.

Books disappear after refresh?

  • Open DevTools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows). Go to the Application tab, then Local Storage. You should see a reading-tracker-books key with JSON data.
  • If the key is missing, check that saveBooks in src/storage.ts uses the exact key reading-tracker-books for both getItem and setItem.
  • Verify that refresh() calls saveBooks(books) before rendering. If save is missing, changes live only in memory.

Help me figure out why my books don't persist after refresh.

Your reading tracker is fully functional. Books move through all three lifecycle states, each state displays its own content and actions, and everything persists across page reloads. Next up, you will add filtering and sorting so you can find specific books quickly.

Add Filter and Sort

Your reading tracker can add books and move them through states. But as your list grows, finding specific books becomes tedious.

In this step, you will wire up the filter and sort dropdowns that already exist in your HTML. This is where your discriminated unions pay off. TypeScript's type narrowing lets you safely access the rating field only on completed books, preventing runtime errors when sorting mixed-status arrays.

In this step, get ready to:
  • Build a filter and sort function that uses type narrowing to safely access state-specific fields.
  • Wire the dropdown controls to refresh the book list on every change.
  • Test that filter and sort work together correctly.
Add the filter and sort logic

The filter dropdown narrows which books appear. The sort dropdown reorders them by rating. Both need to read the current dropdown value, transform the books array, and return the result.

  • In src/main.ts, find the refresh() function. Add the following getFilteredAndSortedBooks() function directly above it:
function getFilteredAndSortedBooks(): Book[] {
  let filtered = books;
  const filterValue = filterSelect.value;

  if (filterValue !== "all") {
    filtered = books.filter((book) => book.status === filterValue);
  }

  const sortValue = sortSelect.value;

  if (sortValue === "rating-desc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingB - ratingA;
    });
  } else if (sortValue === "rating-asc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingA - ratingB;
    });
  }

  return filtered;
}

What does this code do?

This function combines filtering and sorting into a single pipeline that returns the processed array.

  • filterSelect.value reads the current dropdown selection. If it is not "all", the function uses .filter() to keep only books whose status matches.
  • The sort logic uses [...filtered] to create a copy before sorting. This avoids mutating the filtered array in place.
  • The type narrowing a.status === "completed" ? a.rating : 0 safely accesses rating only on completed books. Non-completed books get a rating of 0, pushing them to the bottom.

Why does TypeScript need the status check here?

The Book union type does not always have a rating field. Only Completed books carry that property. Without the status === "completed" check, TypeScript would refuse to compile because it cannot guarantee rating exists on the object.

This is the same discriminated union narrowing pattern you used in your switch statement inside render.ts. The compiler uses the literal status value to narrow the union to a specific variant.

  • Save src/main.ts.

Seeing a TypeScript error on the rating access?

  • Make sure the ternary check uses a.status === "completed" with a triple-equals and a string literal. A typo in the status value prevents TypeScript from narrowing the type.
  • Confirm you are accessing a.rating inside the truthy branch of the ternary, not outside it.

Why won't TypeScript let me access the rating field?

Wire the dropdowns to refresh

The function exists, but it is not called anywhere yet. You need to update refresh() to use it, and attach event listeners so the dropdowns trigger a re-render.

  • In src/main.ts, find the refresh() function. Update it so renderBooks() receives getFilteredAndSortedBooks() instead of the raw books array:
function refresh(): void {
  saveBooks(books);
  renderBooks(bookList, getFilteredAndSortedBooks(), {
    onStartReading: handleStartReading,
    onComplete: handleComplete,
  });
}

What changed here?

Previously, refresh() passed all books directly to the renderer. Now it passes the result of getFilteredAndSortedBooks(), which applies the current filter and sort selections before rendering. Every time anything changes, the UI reflects the active dropdown state.

  • Below the form event listener (near the bottom of the file), add change event listeners for both dropdowns:
filterSelect.addEventListener("change", refresh);
sortSelect.addEventListener("change", refresh);

What does this code do?

Every time the learner picks a different option in either dropdown, the browser fires a change event. These listeners call refresh(), which re-reads the dropdown values inside getFilteredAndSortedBooks() and re-renders the list. No extra state tracking needed.

  • Save src/main.ts.
  • Return to your browser. The app should still display all your books (filter defaults to "All Books").

Page is blank after saving?

  • Check the browser console for errors. A missing closing brace or parenthesis in getFilteredAndSortedBooks() will prevent the whole module from loading.
  • Make sure getFilteredAndSortedBooks is defined ABOVE refresh(). Function declarations are hoisted, but if you accidentally wrote it as a const arrow function, order matters.

Help me debug why my page is blank after adding the filter function.

Test filter and sort together

To properly test, you need books in all three states. If you do not have completed books yet, add a few and transition them through to completion.

  • Add at least three books using the form. Transition two of them all the way to "Completed" with different ratings (for example, one with a 5 and one with a 2).
  • Select Completed in the filter dropdown. Only your completed books should appear.
  • Select Rating: High to Low in the sort dropdown. Your highest-rated book should appear first.
  • Switch to Rating: Low to High. The order should reverse.
  • Switch the filter back to All Books. All books should reappear, with completed books sorted by rating and non-completed books grouped at the bottom (since their effective rating is 0).
  • Refresh the browser page. Your books should persist and the filter/sort should still work after reload.

Sort not changing the order?

  • Make sure your completed books have different ratings. If all books share the same rating, sorting has no visible effect.
  • Check that the sort dropdown option values match exactly: rating-desc and rating-asc in your HTML must match the strings in your if checks.

Help me figure out why my sort dropdown isn't reordering books.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/main.ts.

ⓧ I'd like to double check the full code

Here is the complete src/main.ts with the filter and sort logic in place:

import "./style.css";
import type { Book, WantToRead } from "./types";
import { loadBooks, saveBooks } from "./storage";
import { startReading, completeBook } from "./transitions";
import { renderBooks } from "./render";

let books: Book[] = loadBooks();

const form = document.querySelector<HTMLFormElement>("#add-book-form")!;
const titleInput = document.querySelector<HTMLInputElement>("#title-input")!;
const authorInput = document.querySelector<HTMLInputElement>("#author-input")!;
const filterSelect = document.querySelector<HTMLSelectElement>("#filter-select")!;
const sortSelect = document.querySelector<HTMLSelectElement>("#sort-select")!;
const bookList = document.querySelector<HTMLDivElement>("#book-list")!;

function getFilteredAndSortedBooks(): Book[] {
  let filtered = books;
  const filterValue = filterSelect.value;

  if (filterValue !== "all") {
    filtered = books.filter((book) => book.status === filterValue);
  }

  const sortValue = sortSelect.value;

  if (sortValue === "rating-desc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingB - ratingA;
    });
  } else if (sortValue === "rating-asc") {
    filtered = [...filtered].sort((a, b) => {
      const ratingA = a.status === "completed" ? a.rating : 0;
      const ratingB = b.status === "completed" ? b.rating : 0;
      return ratingA - ratingB;
    });
  }

  return filtered;
}

function refresh(): void {
  saveBooks(books);
  renderBooks(bookList, getFilteredAndSortedBooks(), {
    onStartReading: handleStartReading,
    onComplete: handleComplete,
  });
}

function handleStartReading(id: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "want-to-read"
      ? startReading(book)
      : book
  );
  refresh();
}

function handleComplete(id: string, rating: number, summary: string): void {
  books = books.map((book) =>
    book.id === id && book.status === "reading"
      ? completeBook(book, rating, summary)
      : book
  );
  refresh();
}

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const newBook: WantToRead = {
    id: crypto.randomUUID(),
    title: titleInput.value.trim(),
    author: authorInput.value.trim(),
    addedAt: new Date().toISOString(),
    status: "want-to-read",
  };
  books.push(newBook);
  titleInput.value = "";
  authorInput.value = "";
  refresh();
});

filterSelect.addEventListener("change", refresh);
sortSelect.addEventListener("change", refresh);

refresh();

Your reading tracker is fully functional. Books persist, transition through states, and you can slice the list by status and sort by quality. Next up, a secret mission that takes your data out of the browser and into a downloadable file.

Secret mission

Export Your Reading List

What if you could share your completed reading list with friends? In this bonus challenge, you'll build an export feature that generates a downloadable JSON file of your completed books, sorted from highest to lowest rating. You'll use the Blob API and a type predicate function to make it happen.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The reading-tracker project folder on your Desktop.
  • Browser localStorage data (under the key reading-tracker-books).
  • Vite dev server (running in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or want to pick up exactly where you left off.

  • Your code stays in the reading-tracker folder on your Desktop.
  • Your book data stays in localStorage.
  • Run npm run dev anytime from the project folder to start the dev server again.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your code and localStorage data remain untouched.

  • Switch to the terminal where the Vite dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project files and localStorage data remain. Run npm run dev from the reading-tracker folder when you are ready to continue.

Delete - I don't want to use this again

Remove all project files and stored data to start completely fresh.

  • Stop the dev server by pressing Ctrl+C in the terminal where it is running.
  • Delete the project folder by running this command from your Desktop:

macOS

cd ~/Desktop
rm -rf reading-tracker

Windows

cd ~\Desktop
rmdir /s /q reading-tracker
  • Clear localStorage by opening your browser's DevTools.
  • Go to the Application tab (Chrome) or Storage tab (Firefox).
  • Expand Local Storage in the sidebar.
  • Right-click the http://localhost:5173 entry and select Clear.

Nice Work!

Nice Work!

Nice work! You just built a complete browser-based reading tracker using TypeScript, organized across five modules with clear responsibilities.

You've learned how to:

  • Build a multi-file TypeScript application with clear module boundaries for types, storage, transitions, rendering, and main entry point.
  • Apply discriminated unions and type-safe state transitions in a browser context, where each book state carries only its relevant data.
  • Manipulate the DOM to render data from localStorage, handle events, and filter and sort arrays with type narrowing.
  • Secret Mission: Exported completed books as a downloadable JSON file using the Blob API and a type predicate function.

Ready to quiz yourself?