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
Feature
What it does
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
Feature
What it does
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
Feature
What it does
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
Realise
Screens differ, and a re-import renames things
Insight
Fingerprint the text, and check the import
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.
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
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.
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.
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;
}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.
Check
What it catches
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.