Skip to content

Latest commit

 

History

History
358 lines (259 loc) · 16.7 KB

File metadata and controls

358 lines (259 loc) · 16.7 KB

Kortex — Flashcards that Slap

A PDF-to-flashcards study app with spaced repetition, a TikTok-style browse reel, a social feed, and a brutalist editorial aesthetic.

Developed by G🥷


1. What it does (30-second version)

  1. Drop a PDF — a textbook chapter, a lecture note, anything with a text layer.
  2. Kortex chunks it — semantic paragraph-aware chunker, ~6000 chars per chunk.
  3. An LLM generates cards — Gemini 2.5 Flash Lite with a constrained JSON schema returns teacher-tier Q/A, cloze, concept, and example cards.
  4. SM-2 schedules reviews — the same spaced-repetition algorithm Anki uses; cards resurface as they near forgetting.
  5. Learn / Practice / Browse / Feed — four distinct surfaces for intro, review, discovery, and social context.

2. Tech stack

Layer Choice Why
Runtime / Framework Next.js 14 (App Router) Server components for DB access without a separate API layer, file-based routing, HMR, first-class TypeScript, and Route Handlers that double as the backend.
Language TypeScript 5.6 Type safety across the UI/server boundary. Zod validates at the seam where the LLM output crosses into typed land.
UI React 18 Industry default; pairs with Next's RSC model.
Styling Tailwind CSS 3.4 Utility-first lets the brutalist design system be expressed inline with zero naming overhead. Custom tokens in tailwind.config.ts (ink/bone/acid/lava/violet).
Animation Framer Motion 11 Declarative spring physics for card flips, mascot idle loops, the browse reel swipe, undo toasts, and confetti.
Validation Zod 3 One source of truth for schemas — used for API payloads, LLM output, and form input.
Database better-sqlite3 Synchronous, zero-network embedded DB. Fits the single-machine dev model perfectly and avoids the operational overhead of Postgres/MySQL for a portfolio-scale app. WAL journal mode for concurrent reads.
Auth HMAC-signed session cookies + bcryptjs Stateless — the cookie is the session (no session table). SHA-256 HMAC prevents tampering; timingSafeEqual prevents timing attacks.
LLM Google Gemini 2.5 Flash Lite Fast, cheap, honors structured JSON output. Schema-constrained responses eliminate a full class of parse errors.
PDF parsing pdf-parse Pure-JS text extraction. No native deps, works everywhere Node does.
Package manager npm Default, no build-chain drama.

3. Architecture

┌──────────────── Browser ──────────────────┐
│  React (client components)                │
│  • /practice   SM-2 review loop           │
│  • /learn      intro pass (graduation)    │
│  • /browse     TikTok-style reel          │
│  • /feed       event timeline             │
│  • /upload     PDF → cards flow           │
└────────────┬──────────────────────────────┘
             │ fetch()
             ▼
┌──────────── Next.js Route Handlers ──────────────┐
│  /api/auth/*   signup/login/logout/me            │
│  /api/upload   PDF → chunk → LLM → cards         │
│  /api/review   SM-2 update + review_logs append  │
│  /api/learn/*  graduation flow                   │
│  /api/browse   cross-deck shuffled stream        │
│  /api/feed     event log query                   │
│  /api/practice/[id]  deck queue                  │
└────────────┬──────────────────────────────┘
             │ prepared statements
             ▼
┌─────── better-sqlite3 (data/flashcards.db) ──────┐
│  users · decks · cards · review_logs ·           │
│  feed_events                                     │
└──────────────────────────────────────────────────┘

Why server components + route handlers (no separate backend)

Next.js 14 lets server components read the DB directly and stream HTML — no tRPC, no REST layer for page data. Only mutations and client-triggered fetches go through route handlers. This cuts the project from three mental contexts (client / server / shared types) to two (client / server, with types imported from both).


4. Data model

users          (id, username UNIQUE, passwordHash, createdAt)
decks          (id, name, sourceFilename, createdAt, userId)
cards          (id, deckId, type, question, answer, concept,
                easeFactor, intervalDays, repetitions, dueAt,
                lapses, reviewCount, learnedAt, createdAt)
review_logs    (id, cardId, quality, reviewedAt)
feed_events    (id, userId, type, payload, createdAt)

Design notes

  • Nullable decks.userId — legacy decks created before auth was added automatically get adopted by the first user to sign up (lib/auth.ts signup transaction). Avoids orphan data.
  • learnedAt gates SM-2 — a card must pass through the /learn intro before the practice queue picks it up. Keeps "meet the card" separate from "test the card."
  • feed_events as append-only log — signup, deck_created, card_learned, card_mastered. Storing events (not derived state) means the feed timeline is reconstructible and historical milestones don't disappear when state changes.
  • Indexes on cards(deckId), cards(dueAt), review_logs(cardId), review_logs(reviewedAt), feed_events(userId, createdAt DESC). Every hot query has an index.
  • WAL modejournal_mode = WAL lets readers not block writers. Matters when the upload route is inserting cards while the page is rendering deck stats.

5. Spaced repetition — SM-2

The classic SuperMemo 2 algorithm (see lib/sm2.ts):

quality = {again: 0, hard: 3, good: 4, easy: 5}

if quality < 3:
  lapsed, interval = 1 day, repetitions = 0
else:
  if repetitions == 0: interval = 1 day
  elif repetitions == 1: interval = 6 days
  else: interval = round(interval * easeFactor)
  repetitions += 1

easeFactor += 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)
easeFactor = max(1.3, easeFactor)

Additions on top of vanilla SM-2:

  • masteryPercent — blends repetitions, easeFactor, and lapses into a 0–1 score for the progress bars. Pure SM-2 gives you "next due date" but not a readable "how well do they know this."
  • cardBucket — derives a new | learning | shaky | mastered label. Uses intervalDays ≥ 21 and repetitions ≥ 3 as the mastered threshold — i.e. the card has survived three successful reviews at increasing gaps.
  • struggleScore0.6 * lapseRate + 0.4 * easePenalty. Used to float shaky cards to the front of the practice queue before easier ones.

6. LLM integration

Model: gemini-2.5-flash-lite (overridable via GEMINI_MODEL env var)

Why this model: on Gemini's free tier, Flash Lite offers the best RPM-per-intelligence ratio — ~30 RPM and sub-second first-token latency. The tradeoff vs Flash is a slightly smaller context window, which doesn't matter here because each chunk is <2000 tokens.

Structured output: Gemini's responseSchema enforces the return shape:

{
  type: "qa" | "cloze" | "concept" | "example",
  question: string,
  answer: string,
  concept: string
}[]

Zod then re-validates defensively on receipt. Two layers because the model occasionally violates the schema subtly (empty strings, mislabeled types).

Chunking (lib/chunker.ts):

  • Paragraph-first split on \n\n
  • Fall back to sentence split on over-long paragraphs
  • Target 6000 chars, max 8000, with 240-char overlap so ideas that straddle a boundary aren't lost
  • Bigger chunks = fewer API calls = less rate-limit thrash on the free tier

Rate-limit handling: the upload route concurrency-limits chunks in flight and paces requests to stay inside the free-tier RPM window. Quality filter (lib/quality.ts) drops cards with trivial answers or suspicious length ratios before insert.


7. Auth

Approach: HMAC-signed session cookies. The cookie IS the session.

token = base64url(payload) + "." + HMAC_SHA256(base64url(payload), SECRET)
payload = { uid, u: username, iat }
  • Stateless — no sessions table, no revocation store. For multi-device revocation you'd add one; for this scale it's unnecessary overhead.
  • timingSafeEqual on signature check prevents timing attacks.
  • httpOnly, sameSite: lax, secure in prod — standard cookie hardening.
  • bcrypt cost factor 10 for password hashing.
  • Middleware (middleware.ts) presence-checks the cookie to redirect pre-render. The route handlers still call requireUser() which verifies the HMAC — middleware is just UX polish to avoid a flash of protected content.

Why not NextAuth / Clerk / Supabase Auth: bloat for a two-field form. The entire auth layer is ~100 LOC and has zero network dependencies.


8. Design system

Tokens (tailwind.config.ts):

ink      #0B0B0F   near-black
bone     #F4EFE6   warm cream (light mode base)
charcoal #14131A   dark mode base
slab     #1E1D26   dark mode surface
acid     #D4FF3B   electric lime — primary accent
lava     #FF4D26   hot orange — warnings, "slap"
violet   #5B2BFF   deep electric violet — highlights in dark mode

Typography: Fraunces (display, serif), Inter (body), JetBrains Mono (tags/kbd). All via next/font with CSS variables.

Brutalist signatures:

  • border-2 everywhere, no rounded corners except on kbd
  • Hard offset shadow utilities: .brut, .brut-acid, .brut-lava, .brut-violet
  • .sticker — rotated label with hard shadow (rot-n3, rot-p3, etc.)
  • .highlight — 42% marker-pen band on text. Acid in light mode, violet in dark mode (cream text on acid washes out — violet keeps contrast).
  • Film-grain noise overlay (body::before) for editorial warmth
  • .slab-sm / .slab-md — offset shadow utilities that flip to cream in dark mode so edges stay visible on charcoal

Dark mode: darkMode: "class" on <html>. Theme toggle sets localStorage.theme and flips the class. An inline script in <head> applies the saved theme before first paint — no FOUC.


9. Routes

Route Kind Purpose
/ RSC Home: streak, recent deck, deck browser
/login, /signup Client Auth forms
/decks RSC All decks with search, sort, mastery bars
/deck/[id] RSC Per-deck stats + card inspector
/practice/[id] Client SM-2 review loop with undo toast
/learn/[id] Client Intro pass — reveal → got it / skip
/upload Client PDF picker + fun-facts loader
/browse Client TikTok-style vertical reel across decks
/feed Client Activity timeline
/api/auth/{signup,login,logout,me} Route handler Auth endpoints
/api/upload Route handler PDF → chunks → LLM → card inserts
/api/practice/[id] Route handler Deck queue
/api/review Route handler SM-2 update + log
/api/review/undo Route handler Restore pre-review card snapshot
/api/learn/[id] Route handler Unlearned cards
/api/learn/graduate Route handler Mark card learned
/api/browse Route handler Cross-deck shuffled stream
/api/feed Route handler Event log query

10. Mobile / tablet / desktop

Breakpoints (Tailwind defaults):

  • sm: 640px — phone → tablet boundary
  • md: 768px — small tablet
  • lg: 1024px — desktop
  • xl: 1280px — wide desktop

Mobile specifics:

  • Bottom tab bar (components/MobileNav.tsx): Decks / Browse / Upload / Feed. Hidden sm:hidden. min-h-[56px] per tab for touch targets. paddingBottom: env(safe-area-inset-bottom) for notched devices.
  • Body has pb-20 sm:pb-0 so content doesn't hide under the tab bar.
  • Headlines scale text-4xl sm:text-5xl lg:text-6xl xl:text-7xl on most pages — avoids the "seven-story tall headline on a phone" problem.
  • Mascot shown at 64px on small screens, 120px on sm, 150px on lg.
  • Undo toast floats at bottom-20 sm:bottom-6 so it clears the mobile tab bar.

Tablet specifics:

  • Decks grid: sm:grid-cols-2 lg:grid-cols-3.
  • Hero layouts flex-wrap so the mascot drops below the headline on narrow tablets.
  • Main padding py-6 sm:py-10 lg:py-12 for a gentler vertical rhythm on tablets.

Keyboard support (desktop):

  • Space — flip / toggle
  • 1–4 — rate again/hard/good/easy (practice & browse)
  • ↑↓ / jk — navigate browse reel
  • G / S — got it / skip on /learn
  • U — undo last review
  • Shortcuts modal (?) lists all bindings

11. Mascot — "Kort"

A brain-blob-in-a-bandana SVG mascot (components/Mascot.tsx) with six moods: wave | think | cheer | sleep | study | flex.

Why a mascot:

  • Empty states feel warmer. "No decks yet" + Kort waving ≫ "No decks yet" alone.
  • Reinforces brand identity; makes the app feel like a product, not a CS project.
  • Uses currentColor for strokes so it adapts to dark mode without a separate asset.

Technical notes:

  • Pure SVG, ~120 LOC. No external asset.
  • Framer Motion drives mood-specific idle loops (wave rotates, cheer bounces, sleep emits zs).
  • still prop disables animation — used in the top-left logo so the header doesn't have a distracting bobbing head.
  • Body is lava (orange), bandana is acid (lime). Deliberately clashes for brand memorability.

Placements:

  • Header logo (top-left, 36px, static)
  • Home hero (mobile: inline 64px, desktop: 150px study)
  • Decks / Feed / Upload / Login / Signup heroes (110px)
  • Empty states across Browse, Learn, Practice
  • Done states ("you cooked" — flex mood)

12. UX decisions that weren't obvious

Undo window on reviews

A 5-second undo toast after every rating (practice page). Why: SM-2 punishes "again" ratings with a full interval reset. One mis-click shouldn't cost a week of progress. Implementation (/api/review/undo) stores a snapshot of the pre-review card state on the response and restores it on undo — the re-queued copy is also removed from the tail of the session queue.

Separate Learn and Practice

Pure SM-2 throws you into testing the moment a card exists. That's hostile for brand-new material. /learn adds a "meet the card" pass — reveal → "got it" graduates the card into the SM-2 pool, "skip" doesn't. The cards.learnedAt timestamp is the gate.

Browse reel as a third surface

Practice is goal-oriented (clear the due queue). Browse is discovery-oriented (scroll, tap to flip, optionally rate). TikTok-style swipe on mobile, ↑↓/jk on desktop. Ratings still write through to SM-2 — it's a legitimate review surface, just with a different information diet.

Feed as an engagement hook

Single-user apps are lonely. The feed shows your milestones alongside a sample of other users' events — just enough to feel like there's a room. Events are append-only; UI never mutates them.

The fun-facts loader

Uploads take 10–60 seconds on free-tier rate limits. A spinner is insulting. The loader rotates study-adjacent trivia to reward the wait.


13. Running it

npm install
cp .env.example .env.local   # add GEMINI_API_KEY and AUTH_SECRET
npm run dev                  # http://localhost:3000

Env vars:

  • GEMINI_API_KEY — required. Get one at aistudio.google.com.
  • GEMINI_MODEL — optional. Defaults to gemini-2.5-flash-lite.
  • AUTH_SECRET — required in prod. Random 32+ char string for HMAC signing.

Scripts:

  • npm run dev — Next dev server
  • npm run build — production build
  • npm start — serve the production build
  • npm test — node --test on lib/**/*.test.ts
  • npm run lint — next lint

14. Trade-offs accepted

Trade-off Why accepted
SQLite over Postgres Single-machine dev model. Zero ops. Portable file.
Gemini free tier over paid GPT-4 Cost control during development; quality is sufficient with schema constraints.
Custom HMAC auth over NextAuth 100 LOC vs. a dependency that covers cases we don't need.
No tests on the UI Playwright in a future pass; SM-2 has unit tests (the hard logic).
No image/video cards Scope. The PDF → text → card pipeline is already large.
No mobile app PWA-able later; responsive web covers the primary use case.

15. What's next (roadmap)

  • Export deck as Anki .apkg
  • Mobile PWA with offline review queue
  • Multi-modal cards (diagrams extracted from PDFs)
  • Per-deck difficulty calibration using IRT
  • Shared decks with fork semantics
  • Scheduled "nudge" notifications

Developed by G🥷