All work

Ribbon · reading app · TypeScript

Where was I, to the paragraph

Ribbon is a reader for books I own. It turns an EPUB into its own format, remembers where I am down to the paragraph on every device, and adds quizzes, an AI helper and voice on top. The hard part is finding my place again when everything about the page has changed.

Role

Sole author

Stack

Next.js, PostgreSQL, Drizzle

Tests

About 128 unit, 33 end-to-end

Extras

Quizzes, AI helper, voice

A reading app built around one book at a time

Ribbon is made for reading a serious technical book properly: picking up exactly where you left off, checking you understood each section, and coming back every day.

Reading

Where was I

Your place is saved to the paragraph and synced across devices, with a banner when another device is further ahead.

Book mode

A full-screen open book with 3D page turns and a soft paper sound.

In short cards and diagrams

A short summary and hand-made diagrams for each section, right in the text.

Citations and glossary in place

Tap a citation or a key term and it opens in a popup, without losing your place.

Highlights, notes and bookmarks

Mark up the book as you go, and find it all again later.

Your own PDFs

Upload a PDF and read it the same way.

Learning

Quick checks

A short quiz after each section, with five question types, each tied to the paragraph it came from.

Review deck

Questions come back after 1, 3, 7, 16, 35 and 80 days, so what you read stays read.

AI helper

Ask about the passage you are on and get an answer grounded in the book's own text.

Voice

The book read aloud, so a chapter can carry on away from the screen.

Book overview

Progress and mastery for every section at a glance.

Habit and focus

Streaks, XP, levels and badges

A reason to come back tomorrow, and a record of how far you have come.

You against you

A leaderboard against your own past weeks, plus a friends league through invites.

Focused time only

Reading time counts only while the book is in front, with an optional strict focus mode.

Focus sounds

Background sounds, a live lo-fi radio and your own playlist, without leaving the page.

Reminders

An email at the time you pick, if today's goal is not met yet.

Share cards

A shareable profile and progress card.

A scroll position means nothing on another screen

Saving how far down the page you scrolled works until you open the book on a phone, change the font size, or rotate the screen. The same number of pixels now lands in a different paragraph.

Saving which paragraph you were on is better, but paragraphs need names, and names come from the import. Fix a bug in the importer, run it again, and the ids can shift. Every saved place now points at the wrong paragraph or at nothing.

And the import itself can lose things quietly. A figure the parser did not understand, a footnote with no marker: nothing breaks, the book is just missing a piece.

How I got there

Three passes, in the order they happened.

Explore

Save something that points at the page

Save the scroll
Page number
Save the block id

Realise

Screens differ, and a re-import renames things

Save the scroll
Page number
Save the block id

Insight

Fingerprint the text, and check the import

A reading line
Match the text
Count in and out

Remember the words, not where they happened to be.

Saving a place, and finding it again

A place is a paragraph, a fingerprint of its text, and how far into it you were. Each piece covers a way the others can fail.

  1. A reading line, a fifth of the way down

    Where your eyes are is roughly a fifth of the way down the screen. A binary search over the paragraphs finds the one under that line, and how far into it the line falls, from 0 to 1. It measures a handful of paragraphs, not all of them.

    apps/web/src/lib/reading/logic.ts

  2. Saved often, and on the way out

    To the device every second and to the server every five. When the tab closes, the last save goes out with keepalive or sendBeacon so it is not cut off.

  3. One place per device

    Each device keeps its own place. If another one is further on, a banner offers to jump there, rather than moving you without asking.

  4. Found by id, then by text, then by section

    On opening, the saved paragraph is looked up by id. If a re-import changed the ids, an eight-character hash of its text finds it anyway, with the offset still exact. Only if the text itself changed does it fall back to the start of the section.

The whole lookup

Pure logic with no DOM or database in it, shared by the browser and the server, which is what makes it easy to test.

export function resolveSpot(
  spot: Pick<ReadingSpot, "blockId" | "blockHash">,
  blocks: readonly BlockRef[],
): { blockId: string; exact: boolean } | null {
  if (blocks.some((b) => b.id === spot.blockId)) return { blockId: spot.blockId, exact: true };
  const byHash = blocks.find((b) => b.hash === spot.blockHash);
  if (byHash) return { blockId: byHash.id, exact: true };
  const section = sectionOfBlockId(spot.blockId);
  const bySection = blocks.find((b) => b.section === section);
  return bySection ? { blockId: bySection.id, exact: false } : null;
}
apps/web/src/lib/reading/logic.ts

An import that fails loudly

The importer unzips the EPUB, parses each chapter's HTML into typed blocks, and gives every block an id, a text hash, a word count and its section. Then, before anything is written, it checks its own work. Any error fails the run and the report says what and where.

Counts in the source against counts in the output

Figures, code samples, tables, sections, footnotes and paragraphs, per chapter. A parser that skipped something shows up as a number that does not match.

Every cross-reference and footnote marker resolves

A link to a place that does not exist, or a note marker with no note behind it.

No anchor used twice

Two places with the same name would send a link, or a saved spot, to the wrong one.

Every image exists, and the output matches the schema

The reader and the importer share one Zod schema, so the reader never meets a shape it was not built for.

Book text is never committed. The importer's test fixtures are made-up HTML.

Quizzes, AI and voice

After each section there is a short quiz: multiple choice, true or false, fill the blank, put in order, match the pairs. Every question points at the paragraph it came from. Every answered question joins a review deck that brings it back after 1, 3, 7, 16, 35 and then 80 days. A right answer moves it one step further out; a wrong one starts it again.

Reading time only counts while the tab is in front, with a ten-second allowance for looking away. Reminder emails go out through Resend; a GitHub Actions job calls the reminder endpoint every fifteen minutes, because the free hosting plan only runs scheduled jobs once a day.

An AI helper answers questions about the passage you are on, grounded in the book's own text, and can write study questions for PDFs you upload. Every question it writes must cite the paragraph it came from, or it is rejected.

Voice reads the book aloud, so a chapter can carry on away from the screen.

What Ribbon came down to:

01

A place is words, not pixels

A paragraph, a fingerprint of its text and an offset survive a new screen, a new font and a re-import.

02

Fall back in order

Id, then text, then section. Each step down says honestly whether the offset still applies.

03

Count what you import

Every figure, table and footnote is counted in and out, so nothing goes missing quietly.