Introduction: Why Track Reading Progress?

Welcome back! In the previous lesson, you learned how to build a book catalog and search for books in your reading tracker API. Now, let’s take the next step: tracking your own reading progress and organizing your personal shelf.

Imagine you want to see which books you’ve finished, which ones you’re still reading, and which you want to read next. This is where per-user reading status and the shelf API come in. By the end of this lesson, you’ll understand how to store and update your reading progress and how to view your shelf with filters and sorting options.

How Per-User Reading Status Works

Each user can have their own reading progress for each book. This is stored in the ReadingSession object. The key fields are:

  • userId: Whose progress is this?
  • bookId: Which book?
  • currentPage: What page is the user on?
  • status: What is the reading status? (optional, but useful)

The status can be one of:

  • "not-started"
  • "in-progress"
  • "completed"
  • "want-to-read"

Let’s look at how a reading session is updated in the service:

// src/reading/reading.service.ts (excerpt)
  updateProgress(dto: UpdateProgressDto) {
    // Ensure user and book exist
    // Validate user exists
    if (!this.db.findUserById(dto.userId)) {
      throw new NotFoundException('User not found');
    }
    const book: any = this.booksService.findOne(dto.bookId);

    let session = this.db
      .getReadingSessions()
      .find((s: any) => s.userId === dto.userId && s.bookId === dto.bookId);

    const normalized =
      dto.status === 'want-to-read'
        ? { currentPage: 0, status: 'want-to-read' as const }
        : { currentPage: dto.currentPage, status: dto.status ?? this.deriveStatus(dto.currentPage, book.totalPages) };

    if (session) {
      session.currentPage = normalized.currentPage;
      (session as any).status = normalized.status;
      (session as any).updatedAt = new Date().toISOString();
    } else {
      session = { userId: dto.userId, bookId: dto.bookId, currentPage: normalized.currentPage, status: normalized.status, updatedAt: new Date().toISOString() };
      this.db.getReadingSessions().push(session);
    }
    return session;
  }

What it does
Upserts a user’s reading session for a given book and normalizes status. The method guarantees the user/book exist, derives status when not explicitly provided, and stamps updatedAt so the client can order shelf activity.

Execution flow

  1. Existence checksusersService.findOne(dto.userId) and booksService.findOne(dto.bookId) throw if the user/book doesn’t exist, preventing orphan sessions.
  2. Normalization
    • If dto.status === 'want-to-read', the method forces { currentPage: 0, status: 'want-to-read' }, ignoring any incoming currentPage. This models intent (“queue this book”) and prevents phantom progress.
    • Otherwise, it sets currentPage = dto.currentPage and status = dto.status ?? deriveStatus(currentPage, totalPages).
    • deriveStatus rules: currentPage <= 0 → 'not-started'; currentPage >= totalPages (with totalPages > 0) → 'completed'; else 'in-progress'.
  3. Upsert — Looks up an existing session by (userId, bookId).
    • If found, it mutates currentPage, status, and updatedAt.
    • If absent, it pushes a new session with those fields plus userId, bookId.
  4. Return — Returns the persisted session object (useful for immediate UI reconciliation).

Explanation:

  • The method checks if a reading session already exists for the user and book.
  • If it does, it updates the progress and status.
  • If not, it creates a new session.
  • The status is set based on the current page and total pages, or directly from the user’s input.

Example Output:

Suppose Alice (userId: 2) updates her progress on "The Hobbit" (bookId: 1) to page 50:

{
  "userId": 2,
  "bookId": "1",
  "currentPage": 50,
  "status": "in-progress",
  "updatedAt": "2024-06-01T12:00:00.000Z"
}
Building the Shelf API Endpoint

Now, let’s see how to build the endpoint that lets a user view their personal shelf. This endpoint will show all books the user is tracking, along with their progress and status.

Here’s the main part of the controller and service:

// src/reading/reading.controller.ts
  @Get('shelf')
  @UseGuards(JwtAuthGuard)
  // Get the authenticated user's shelf with optional filters/sorting
  getShelf(@CurrentUser() user: TokenUser, @Query() query: FindShelfDto) {
    const data = this.readingService.getShelf(user.userId, query);
    return { success: true, data };
  }
// src/reading/reading.service.ts
  getShelf(userId: string, query: FindShelfDto) {
    if (!this.db.findUserById(userId)) throw new NotFoundException('User not found');
    const books = this.db.getBooks();
    const byId = new Map(books.map((b: any) => [String((b as any).id), b]));

    let items = this.findAllForUser(userId).map((s: any) => {
      const book = byId.get(String((s as any).bookId))! as any;
      const total = book.totalPages || 0;
      const status = (s as any).status ?? this.deriveStatus(s.currentPage, total);
      const progress = total ? s.currentPage / total : 0;
      return {
        bookId: book.id,
        title: book.title,
        author: book.author,
        totalPages: total,
        currentPage: s.currentPage,
        status,
        progress,
        updatedAt: (s as any).updatedAt ?? null,
      };
    });

    if (query.status) items = items.filter((i) => i.status === query.status);

    const order = query.order ?? 'asc';
    const cmpNum = (a: number, b: number) => (a < b ? (order === 'asc' ? -1 : 1) : a > b ? (order === 'asc' ? 1 : -1) : 0);
    const cmpStr = (a: string, b: string) => cmpNum(a.localeCompare(b), 0);

    if (query.sortBy === 'title') items.sort((a, b) => cmpStr(a.title, b.title));
    if (query.sortBy === 'author') items.sort((a, b) => cmpStr(a.author, b.author));
    if (query.sortBy === 'progress') items.sort((a, b) => cmpNum(a.progress, b.progress));
    if (query.sortBy === 'updatedAt') items.sort((a, b) => cmpStr(a.updatedAt || '', b.updatedAt || ''));

    return items;
  }
}

What it does
Builds the current user’s shelf by joining reading sessions with book metadata, computing normalized status and a numeric progress, then applying optional filtering (status) and sorting (title, author, updatedAt, progress) with a specified order.

Execution flow

  1. Validate & prepare — Confirms the user exists, loads all books once, and constructs a Map<bookId, Book> for O(1) lookups.
  2. Compose items — For each of the user’s sessions:
    • Fetch the corresponding book.
    • Compute total = book.totalPages and status = session.status ?? deriveStatus(currentPage, total). This ensures older sessions without stored status still behave correctly.
    • Compute progress = total ? currentPage / total : 0. (This yields a 0–1 ratio in normal cases; if pages exceed total, the ratio can be >1—clamp in UI if desired.)
    • Emit an enriched item: { bookId, title, author, totalPages, currentPage, status, progress, updatedAt }.
  3. Filter — If query.status is present, keep only items with that status (e.g., 'want-to-read').
  4. Sort — Uses stable numeric/string comparators:
    • title/author: locale-aware localeCompare.
    • progress: numeric ascending/descending.
    • updatedAt: string compare on ISO timestamps; null is treated as '', so it sorts first in ascending, last in descending.
    • Default order is 'asc' when not specified.

Why it returns enriched items (not raw sessions) Shelf consumers (UI/tests) need immediately renderable data: book identity for display, normalized status for badges, and a numeric progress for bars/sorting. Returning a pre-joined, computed structure keeps clients thin and guarantees consistent derivation rules across views.

Explanation:

  • The endpoint returns all reading sessions for the current user.
  • Each session is enriched with book details and progress.
  • The results can be filtered by status and sorted by different fields.
Using Query Parameters: Examples

You can customize your shelf view using query parameters. Here are some examples:

  • Filter by status:
    /reading/shelf?status=completed
    Shows only books you have completed.

  • Sort by title (descending):
    /reading/shelf?sortBy=title&order=desc
    Shows your shelf sorted by book title, from Z to A.

  • Sort by progress:
    /reading/shelf?sortBy=progress&order=asc
    Shows books with the least progress first.

Example Output:

Suppose Alice has two books on her shelf:

[
  {
    "bookId": "1",
    "title": "The Hobbit",
    "author": "J.R.R. Tolkien",
    "totalPages": 310,
    "currentPage": 50,
    "status": "in-progress",
    "progress": 0.16,
    "updatedAt": "2024-06-01T12:00:00.000Z"
  },
  {
    "bookId": "2",
    "title": "Dune",
    "author": "Frank Herbert",
    "totalPages": 412,
    "currentPage": 0,
    "status": "want-to-read",
    "progress": 0,
    "updatedAt": "2024-06-01T12:05:00.000Z"
  }
]

If Alice requests /reading/shelf?status=want-to-read, she will see only "Dune" in the results.

Test the Shelf API (Unit-2 scope only)
Summary and Practice Preview

In this lesson, you learned how to track each user’s reading progress and status, and how to build an API endpoint that lets users view and organize their personal shelf. You saw how to filter and sort your shelf using query parameters, making it easy to find the books you want to focus on.

Next, you’ll get to practice these concepts by updating your own reading progress and exploring your shelf with different filters and sorting options. This hands-on practice will help you become comfortable with managing per-user reading status and using the shelf API effectively.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal