Code by

Carter

Phan

Notion Interview Memory

Notion Interview Memory

Year2026
Next.js 14BunTypeScriptbun:sqliteFSRSNotion APIOpenAI-compatible

Project Description

A local-first spaced-repetition app that turns a Notion knowledge base into deadline-aware interview prep, with a modified FSRS scheduler, MCQ weakness diagnostic, and fixed-shape weekly sprint.

Anki works for people who plan to keep learning a topic for years. It does not work for someone with an interview in 21 days.

The classic spaced-repetition contract is that you review a card, rate it "good," and the algorithm rewards you with a longer interval — 3 days, 7 days, 21, 45, 90. That schedule is optimized for a lifetime of retention, which is the exact opposite of what an interview candidate wants. If my interview is on the 15th and the scheduler pushes my next review to day 21, that card has effectively left the study set. Everything the algorithm decides after the deadline is wasted computation.

I built Notion Interview Memory because I was living that failure mode. My prep material lived in a Notion database. My retention was leaking. And every open-source flashcard tool assumed I had infinite time. So I built a local Bun + Next.js app that turns my Notion pages into cards, uses a modified FSRS scheduler that respects a hard deadline, and adds two review modes that Anki-shaped tools don't have: a diagnostic MCQ scanner and a fixed-shape weekly sprint.

The Interview Date Clamp — Modifying FSRS for a Deadline

The core algorithm is a lightweight FSRS variant. Each card has a stability, a difficulty, an elapsed-days counter, and a schedule state. A good rating grows stability by ~2.5×, easy grows it by ~3.6×, hard grows it by ~1.4×, and again divides it by ~2.2 and resets the interval to 5 minutes. Standard stuff.

The interesting part is the clamp. Every schedule update passes through this:

typescript
export function applyInterviewDateClamp(
  scheduledDays: number,
  reviewDate: Date,
  interviewDate: Date | null,
): number {
  if (!interviewDate) return scheduledDays;
  const daysUntil = Math.floor(
    (interviewDate.getTime() - reviewDate.getTime()) / 86_400_000,
  );
  if (daysUntil <= 1) return 0;
  return Math.min(scheduledDays, daysUntil - 1);
}

Every card gets at least one more review before the interview, no matter what FSRS wanted. If the algorithm says "review in 21 days" but the interview is 8 days away, the schedule becomes 7 — leaving a full day of buffer for the final pass. As the deadline gets closer the clamp gets tighter; on the last three days every card in the deck is on a 1-day rotation regardless of how well I know it, because at that point the cost of over-reviewing is negligible and the cost of a surprise gap is catastrophic.

The clamp is one line of production code and it changed the whole psychology of the tool. Before it, the app was a spaced-repetition trainer that happened to know about my interview. After it, the app was an interview-prep trainer that happened to use spaced repetition.

Two Review Modes, One History

Open-recall cards are the traditional flow — flip the card, answer in prose, self-grade with again, hard, good, or easy. That grade is the only thing the scheduler cares about. Optional AI critique appears alongside my answer; I read it, take what's useful, and still grade myself. The AI's job is to add signal, not to be the judge.

MCQs are the parallel track. When a Notion note gets synced, an AI pass generates both open-recall drafts (which require manual approval) and multiple-choice questions (auto-approved because a wrong answer is objectively wrong and doesn't need my curation). Each MCQ has one correct answer, three plausible distractors, and shuffles on load.

Both review types write into the same review history. On the History screen they merge into a single timeline with type badges — Open Recall or Multiple Choice — filterable by tag and type. This matters because the dashboard heatmap computes retention per tag across both modes. A tag where my open-recall grades are good but my MCQs are consistently wrong is a tag where I've memorized the shape but don't understand the substance — and that gap is invisible if you look at either data stream alone.

The Weakness Diagnostic — 15 Questions, One Report

The diagnostic is the piece I use most. Fifteen MCQs, weighted sampling from cold or stale tags (tags I haven't reviewed recently, or tags where my retention is trending down), and at the end a Weakness Report:

code
Weakness Report
─────────────────
Distributed systems ······· 20% (4/5 stale)
Java concurrency ·········· 40% (2/5)
Postgres internals ········ 80% (green)

▶ Drill these tags in open-recall

Clicking the drill button hands off directly into an open-recall session filtered to those two red tags. It's a two-click flow from "I have no idea what to study" to "I am studying the exact right thing." I designed the report to be embarrassingly simple on purpose — the value is not in a beautiful chart, it's in removing every decision between diagnosis and action.

The MCQ pool is separately seeded from the drafts pool, so the diagnostic isn't just re-surfacing the exact questions I've already answered. And because MCQs auto-approve, I always have coverage across the whole knowledge base — no "empty deck" problem.

Sprint — Same Shape Every Week

The Sprint mode is 20 items, always: roughly 50/50 MCQ and open-recall, 70% weighted toward tags that were red or yellow on the last heatmap. Fixed shape, always. That constraint is the whole point.

If every session had a different length or composition, I couldn't compare Sprint #4 to Sprint #7 and know whether I'd actually improved. By making the shape identical, my raw score becomes a benchmark. Sprint #4 was 62%, Sprint #7 was 78%, and that delta is trustworthy because the sampling distribution was matched. The Countdown widget on the dashboard shows a rolling average of Sprint scores — a running proxy for "am I actually getting better."

Sprints feed into full FSRS state updates like any other review, so they're not just a diagnostic — they are studying. The reason to run one instead of a normal review session is the pressure: same rules every week, timer visible, no half-answers.

The Dashboard — Countdown as North Star

The home screen is a mission-control layout with four tiles.

Countdown is the biggest tile. Days until interview, latest Sprint score, rolling average, percentage of tags currently green. It's deliberately large — the whole point of the tool is that the deadline is real, and the UI should never let me forget it.

Heatmap is a grid of tag tiles, each showing retention rate, a trend arrow, and a status dot (green / yellow / red / cold). Cold tiles are tags I haven't touched in over a week — often more dangerous than red ones, because I don't have recent evidence about how well I know them. Every tile is clickable; clicking drills into open-recall filtered to that tag.

Lapses shows every card I graded again or hard in the last 7 days. This is the night-before-the-interview tile. One click drills every card that hurt me recently, in the order they hurt me.

Due Queue is what's due right now. Not fancy. Just the list.

The four tiles compose the entire study strategy without me having to think about it. If Sprint average is trending up and no tags are red or cold, keep going. If a tag went cold, click it. If Lapses is full, drill Lapses first. Every decision has a tile that answers it.

Local-First, Offline-First

Every piece of state lives in a single SQLite file under data/app.sqlite, accessed through bun:sqlite. There is no cloud sync, no account, no telemetry. The Notion API is called only during explicit sync operations; between syncs the app is fully offline.

USE_MOCK in src/lib/mock-data.ts swaps the entire API layer for an in-memory fixture. Flipping it lets me demo the full app — every screen, every interaction — without any Notion credentials or AI keys. This is not a "demo mode" bolted on for a landing page; it's how I develop most of the UI, because it removes network flake from the loop.

The AI provider layer accepts any openai-compatible endpoint, and there's an offline mode that skips AI entirely and generates placeholder critiques. That fallback matters more than it looks like it should — it means I can practice on a plane, in a train, or on a spotty cafe wifi, and the tool still works. Spaced repetition doesn't tolerate skipped days well; a tool that requires network access is a tool that will fail me on the day I need it most.

What I Learned

This was the first project I built that started from a specific personal deadline instead of "wouldn't it be interesting to explore X." That framing produced better software. Every feature had to defend itself against the question "will this help me on the day of the interview?" — and features that couldn't answer that question got cut.

Three things I'd carry into any deadline-driven tool:

  1. A hard deadline changes the algorithm, not just the UI. The Interview Date Clamp is one function, but it inverts the assumption behind the whole scheduler. Almost every "learning tool" quietly assumes infinite time; explicitly encoding the deadline is what separates a study tool from a cram tool, and cramming is the honest name for what interview prep actually is.

  2. Give diagnostics a one-click bridge to action. The Weakness Report is small, ugly, and it works because clicking a tag drills the tag. Reports that end at "here is your problem" fail because the user is now stuck picking a next step; reports that end at "start studying this" don't fail.

  3. Fixed-shape benchmarks beat flexible ones. The 20-item Sprint is deliberately rigid so its scores are comparable across weeks. Every "flexible" review session I've built before this looked more sophisticated and told me less.

The whole app is around 4,000 lines of TypeScript, most of them tests. It runs on a single Bun process, hits a single SQLite file, and does exactly one thing well: keep me ready for the interview on the day the interview happens.