featured image

The Highlight Is the Card: Implementing FSRS in SQLite Without a Separate Cards Table

Most spaced-repetition apps split reading notes and review cards across two tables and require a 'convert to flashcard' step. FreedWise collapses them: every highlight carries its full FSRS state as columns, a single transaction grades a card and appends to the review log, and fallback scheduling ensures algorithm failures never block a review session.

Published

Tue Sep 15 2026

Technologies Used

TypeScript SQLite FSRS React Native Expo
Intermediate 21 minutes

The default architecture for a spaced-repetition reading app has two tables. One for highlights — the raw text you extracted from a book, your note, the source location. One for cards — the scheduled review item, with its algorithm state, due date, and review history. You create a highlight, then decide whether to “convert” it to a card. That conversion step is where users drop off. Most highlights never become cards.

FreedWise skips the conversion. Every highlight is a card from the moment it’s created. There’s no second table, no promotion step, no sync problem between what you highlighted and what you review. The FSRS scheduling state lives directly on the highlights row.

What You Need Coming In

  • Comfortable with TypeScript, SQLite basics, and React Native
  • Familiar with what spaced repetition does conceptually — the idea that reviewing at increasing intervals improves long-term retention
  • No prior FSRS knowledge needed; the algorithm details are explained as they come up

The Schema Decision

The highlights table carries two categories of columns that live side by side: the reading data (what you highlighted and where) and the scheduling state (what FSRS needs to decide when to show it next).

CREATE TABLE IF NOT EXISTS highlights (
  -- Reading data
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  text TEXT NOT NULL,
  note TEXT,
  color TEXT NOT NULL DEFAULT '#FFD700',
  position_data TEXT NOT NULL,     -- JSON: CFI for EPUB, page+rect for PDF
  is_flashcard INTEGER NOT NULL DEFAULT 0,
  flashcard_question TEXT,
  is_discarded INTEGER NOT NULL DEFAULT 0,

  -- FSRS scheduling state
  due_date INTEGER NOT NULL,       -- Unix ms: when to show next
  stability REAL NOT NULL DEFAULT 0,
  difficulty REAL NOT NULL DEFAULT 0,
  elapsed_days INTEGER NOT NULL DEFAULT 0,
  scheduled_days INTEGER NOT NULL DEFAULT 0,
  reps INTEGER NOT NULL DEFAULT 0,
  lapses INTEGER NOT NULL DEFAULT 0,
  state TEXT NOT NULL DEFAULT 'new',
  last_reviewed_at INTEGER,

  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE
);

stability and difficulty are the FSRS algorithm’s internal parameters — they encode how well you know this material and how hard it is for you specifically. state tracks the card’s lifecycle: new → learning → review, with relearning when you forget something you’d previously mastered. lapses counts how many times a card has regressed from review back to relearning.

due_date as an integer Unix timestamp lets the due-cards query stay simple: WHERE due_date <= ? with Date.now() as the parameter. No date parsing in SQL, no timezone arithmetic.

New highlights initialize with due_date = Date.now(), making them immediately due for their first review. FSRS handles the new state as a special case — first exposure to a card isn’t the same as a review, and the algorithm’s initial scheduling reflects that.

Querying Due Cards

Getting the review queue is a single indexed query:

async getDueHighlights(date?: Date): Promise<Highlight[]> {
  const targetDate = date ? date.getTime() : Date.now();
  const rows = await this.db.executeQuery<HighlightRow>(
    `SELECT * FROM highlights
     WHERE due_date <= ? AND is_discarded = 0
     ORDER BY due_date ASC`,
    [targetDate]
  );
  return this.mapper.toHighlights(rows);
}

idx_highlights_due_date covers this query. Without the index, reviewing with a large library would do a full table scan on every session start. is_discarded filters out highlights the user tagged with .discard — they wanted to keep the text but didn’t want it scheduled for review.

Grading a Card: The Transaction

When a user grades a card, three things need to happen atomically: the FSRS state on the highlight updates, the due_date advances to the next scheduled interval, and a new row appears in review_logs. If any of these fail, none should commit. A partial write — state updated but log not appended — would corrupt the algorithm’s input on the next grade.

The gradeCard() function handles this in a single database transaction:

async gradeCard(highlightId: string, grade: Grade): Promise<SchedulingResult> {
  const highlight = await this.getHighlightForReview(highlightId);

  // Build a ts-fsrs Card from the current DB state
  const card: Card = {
    due: highlight.dueDate,
    stability: highlight.stability,
    difficulty: highlight.difficulty,
    elapsed_days: highlight.elapsedDays,
    scheduled_days: highlight.scheduledDays,
    reps: highlight.reps,
    lapses: highlight.lapses,
    state: fsrsStateFromString(highlight.state),
    last_review: highlight.lastReviewedAt,
  };

  const now = new Date();
  let schedulingResult: SchedulingResult;

  try {
    const fsrs = new FSRS(generatorParameters());
    const scheduling = fsrs.repeat(card, now);
    const next = scheduling[gradeToRating(grade)].card;
    schedulingResult = { card: next, usedFallback: false };
  } catch (err) {
    // Don't let algorithm failures block reviews
    schedulingResult = this.buildFallbackResult(grade, now);
  }

  const { card: nextCard, usedFallback } = schedulingResult;

  await this.db.transaction(async tx => {
    await tx.executeUpdate(
      `UPDATE highlights SET
        due_date = ?, stability = ?, difficulty = ?,
        elapsed_days = ?, scheduled_days = ?, reps = ?,
        lapses = ?, state = ?, last_reviewed_at = ?, updated_at = ?
       WHERE id = ?`,
      [
        nextCard.due.getTime(), nextCard.stability, nextCard.difficulty,
        nextCard.elapsed_days, nextCard.scheduled_days, nextCard.reps,
        nextCard.lapses, fsrsStateToString(nextCard.state),
        now.getTime(), now.getTime(), highlightId,
      ]
    );

    await tx.executeUpdate(
      `INSERT INTO review_logs
        (id, highlight_id, grade, reviewed_at, elapsed_days, scheduled_days, state)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [
        generateId(), highlightId, grade, now.getTime(),
        nextCard.elapsed_days, nextCard.scheduled_days,
        fsrsStateToString(nextCard.state),
      ]
    );
  });

  return { ...schedulingResult, usedFallback };
}

The ts-fsrs library handles the algorithm math. It takes the current card state and a grade (again, hard, good, easy), returns scheduling options for each possible grade, and you pick the one matching what the user actually selected. The Card object going in and the Card object coming out have the same shape — it’s a pure function over the scheduling state.

When FSRS Fails

ts-fsrs is a port of an algorithm with edge cases. Cards with unusual state combinations — particularly those created via bulk import or manual edits — can cause the scheduler to throw. If gradeCard() propagated that exception, the review session would hard-crash on whatever card triggered it.

The fallback is intentionally simple:

private readonly fallbackDays: Record<Grade, number> = {
  again: 1,
  hard: 3,
  good: 7,
  easy: 14,
};

private buildFallbackResult(grade: Grade, now: Date): SchedulingResult {
  const days = this.fallbackDays[grade];
  const due = new Date(now.getTime() + days * 86_400_000);
  return {
    card: {
      ...baseCard(),
      due,
      scheduled_days: days,
      state: CardState.Learning,
    },
    usedFallback: true,
  };
}

The usedFallback flag propagates to the UI, which shows a soft warning rather than a hard error. The review still records to review_logs, the due_date still advances, and the user can keep going. One malformed card doesn’t bring down the session.

The fallback intervals aren’t arbitrary — they approximate what FSRS would produce for a typical card at each grade, without needing any algorithm state. again tomorrow, hard in three days, good in a week, easy in two weeks. Close enough to keep reviews roughly scheduled without the per-card calibration that makes FSRS actually useful.

Preloading the Review Queue

Database reads during rapid grading add latency that’s noticeable when users grade a card and immediately see the next one. The review session preloads a small queue of upcoming cards into memory to avoid that:

private readonly cardCache = new Map<string, Highlight>();
private static readonly PRELOAD_SIZE = 5;

async preloadNextCards(): Promise<void> {
  const rows = await this.db.executeQuery<HighlightRow>(
    `SELECT * FROM highlights
     WHERE due_date <= ? AND is_discarded = 0
     ORDER BY due_date ASC
     LIMIT ?`,
    [Date.now(), FSRSService.PRELOAD_SIZE]
  );
  const highlights = await this.mapper.toHighlights(rows);
  this.cardCache.clear();
  for (const h of highlights) {
    this.cardCache.set(h.id, h);
  }
}

gradeCard() checks the cache before hitting the database. After grading, the review screen removes the graded card from the cache and starts the next one. When the cache empties — five cards in — another preloadNextCards() call fires in the background.

Five cards is the right balance for this app’s review patterns: most sessions are 10-20 cards, and users occasionally stop mid-session. Caching too many cards means stale data if the user closes and reopens the app; caching too few means database hits are frequent.

The Review Log

review_logs is append-only. Nothing ever updates a log row — once written, it’s a historical fact.

CREATE TABLE IF NOT EXISTS review_logs (
  id TEXT PRIMARY KEY,
  highlight_id TEXT NOT NULL,
  grade TEXT NOT NULL CHECK(grade IN ('again', 'hard', 'good', 'easy')),
  reviewed_at INTEGER NOT NULL,
  elapsed_days INTEGER NOT NULL,
  scheduled_days INTEGER NOT NULL,
  state TEXT NOT NULL,
  FOREIGN KEY (highlight_id) REFERENCES highlights(id) ON DELETE CASCADE
);

The log serves two purposes. First, debugging: when the scheduling for a card looks wrong, the log shows the exact sequence of grades and state transitions that produced the current state. Second, eventual retraining: if you ever wanted to retrain an FSRS model on this user’s actual review behavior, the log has everything you need. elapsed_days and scheduled_days at each review are what FSRS’s optimizer uses to tune stability and difficulty estimates.

The ON DELETE CASCADE ensures that deleting a highlight (removing it from the library) also cleans up its review history. The highlight and its entire scheduling history live or die together.

Respecting your privacy.

← View All Tutorials

Related Projects

    Ask me anything!