Problem
I wanted to study from my own engineering notes without a backend, an account, or a paid flashcard SaaS - and I wanted to swap scheduling algorithms without rewriting the product. Existing tools either lock content behind proprietary formats or ship a single opaque scheduler with no way to measure alternatives.
The knowledge already lived next to the blog. Duplicating it into Anki decks or a third-party app would fork the corpus: tags drift, questions detach from the posts they teach, and "update the article" no longer means "update the study set."
The real design problem was not another flashcard UI. It was a platform seam: author once in the content repo, deliver a study surface that stays offline-capable, algorithm-pluggable, and honest about what "zero accounts" costs.
Solution
The quiz is the fourth static consumer of prj--personal-portfolio--v3's shared content artifact. Questions are authored as MDX co-located with posts (…/{slug}/questions/{slug}--{uid}.mdx), ingested into the same SQLite content.db as the Astro sites, then compiled by tools--quiz-export into versioned static JSON.
The web app is a strict CSR React 19 + Vite PWA ("The Typeset Review"): it fetches JSON shards, tracks learning state in the browser (Zustand + versioned localStorage), and rates cards through a pluggable Scheduler interface. Presentation lives as Storybook-backed blocks in shared--ui; this app owns containers, hooks, routes, store, and algorithms only.
No runtime database. No user accounts. Content and progress never share a blob - progress is keyed by question slug so re-exports and set adds do not orphan schedules.
Architecture
AUTHOR content--paulserban.eu
publish/…/{slug}.mdx
publish/…/questions/{slug}--{uid}.mdx
│ content-sync -> mdx-ingest
▼
content.db (same artifact as portfolio / blog / news-feed)
│ tools--quiz-export (JSON contract v2)
▼
public/data/
posts.json · tags.json <- PWA precache indexes
questions/<post>.json <- lazy set shards
tags/<tag>.json · _all.json <- tag study + full offline
│ fetch() + vite-plugin-pwa
▼
CSR React PWA (quiz.paulserban.eu)
├── TanStack Router (browse / sets / tags / study / stats / settings)
├── Zustand persist key quiz-web-app:v1
├── Scheduler seam: SM-2 ⇄ FSRS-5 (lossless migrate)
├── shared--ui blocks (StudyCard, QuestionRenderer, …)
└── Stats + backup / restore
| Layer | Responsibility |
|---|---|
shared--question-contract | Zod frontmatter; answer_format × cognitive_style; derived grading_mode |
tools--mdx-ingest | Co-located questions/ -> questions / question_options with FK to parent post |
tools--quiz-export | DB -> compiled HTML stems -> sharded JSON v2 + copied assets |
shared--markdown | One sanitize / compile path (DOMPurify allow-list) for export and client |
shared--ui blocks | Pure props-in / callbacks-out study chrome; Storybook-backed |
quiz-web-app | Routes, containers, hooks, store, schedulers, PWA shell |
Approach
As architect and lead implementer, I treated the quiz as a delivery boundary on the content platform - not a standalone product that invents its own corpus.
Platform boundaries first
- Fourth consumer of one artifact - the same
content.dbthat feeds three Astro SSG sites; the browser never opens SQLite, so export is an explicit JSON contract (version: 2with precompiledstemHtml/explanationHtml). - Co-located questions - flashcards live beside the post they teach; slug convention
{post-slug}--{uid}plus a foreign key keeps study material from silently detaching. - UI ownership rule - new quiz chrome lands in
shared--uias a block; the app adds a thin container/hook. Blocks must not import the store; containers must not own presentation. That keeps a future blog quiz widget from forking the UI. - Seam over premature packages - early drafts proposed
sr-engine,storage, Capacitor mobile, and a constellation of micro-packages. As-built kept the scheduler interface and UI blocks as the real seams and dropped the rest until reuse is proven.
Scheduler as a product decision
- One interface -
Scheduler { applyReview, previewInterval, migrate }with implementations for SM-2 (default, transparent) and FSRS-5 (19-weight vector, target retention). - Lossless, reversible migration - switching in Settings remaps every card; each
ReviewLogrecords which scheduler produced it. - Evidence, not vibes - a back-test harness simulates a deck at the same retention target; on the reference run FSRS hits ~90% retention with roughly 36% fewer reviews than SM-2.
- Correctness beyond the algorithm - shared learning / relearning FSM, interval fuzz, due-date load balancing, Anki-like day rollover (default 04:00), per-set and global daily caps, leech detection and suspend.
Study product surface
- Scopes - one set, a tag, or every due card across added sets; cram mode for a card or whole tag; global browse with format / difficulty / tag / state filters; preview outside a session.
- Four answer formats - free text (self-grade), multiple choice, true/false, multiple select (auto-grade) - orthogonal to cognitive style (
factual_recall,comprehension,application, …). - Offline PWA - installable shell; precached indexes; per-set / per-tag shards cached stale-while-revalidate so opened sets stay available offline.
- Stats and settings - 30-day forecast, 1-year heatmap, ease / difficulty distributions, reviews/day, retention windows, FSRS memory-model panel; theme, study order, scheduler knobs, backup/restore, clear-all - still zero accounts.
Stack
| Layer | Choices |
|---|---|
| Authoring | MDX questions co-located with posts; Zod question contract |
| Pipeline | mdx-ingest -> content.db -> quiz-export JSON v2 |
| App | React 19, Vite, TanStack Router, Zustand persist, Tailwind / newspaper DS |
| Algorithms | SM-2 + FSRS-5 behind Scheduler; shared learning FSM + fuzz |
| Offline | vite-plugin-pwa, precache indexes, SWR for /data/* |
| Hosting | Static CDN origin at quiz.paulserban.eu |
Design decisions that mattered
- CSR + static JSON over accounts and browser SQLite. Fits the platform's JAMstack edge; zero ops surface for v0.1; progress stays local and exportable.
- Answers ship to the client. Honest threat model for a personal study tool - revisit only if graded / multi-user backends appear.
- Slug-keyed progress, not row ids. Survives re-export and additive set adds; remove-post keeps card states so re-adding restores the schedule.
- One sanitize path. Export-time compile and client rendering share
shared--markdown, closing a split XSS risk. - Ship both schedulers with a migration story. Research literacy without stranding users who prefer SM-2's transparency.
- Trade-off named up front. Mobile / Capacitor and a blog embed widget deferred;
_all.jsonkeeps the mobile door open without building the wrapper yet.
What shipped
- End-to-end path: co-located MDX -> ingest -> export -> offline PWA on quiz.paulserban.eu
- Dual SM-2 / FSRS-5 seam with lossless migration and a review-load back-test harness
- Multi-scope study (set / tag / global / cram), four answer formats, rich MDX stems (KaTeX + highlighted code)
- Versioned client state (
quiz-web-app:v1) with backup/restore and one-shot persist migrations - Stats almanac (forecast, heatmap, retention, FSRS memory panel) and leech management
- Presentation extracted into
shared--uiblocks so the app stays orchestration-thin
Outcome
A genuinely offline-capable study tool that reuses the blog's content with zero duplication, treats the scheduler as a swappable seam with measurable trade-offs, and keeps "zero accounts" as an architectural choice rather than a missing feature. The quiz is not a side project bolted on - it is another delivery shape of the same content platform contract: author once, fan out many times.
Part of prj--personal-portfolio--v3
This PWA is the study surface of the monorepo. Related pieces:
- The content pipeline that produces
content.dband runsquiz-export - Shared newspaper design system - home of the quiz UI blocks
- Local HTTPS Traefik + Docker Compose serving
local.quiz.paulserban.eu - AWS multi-subdomain hosting - production edge for quiz.paulserban.eu