A multilingual scripture‑memorization tool — one screen at /review, backed by
Supabase, a pure Leitner‑box spaced‑repetition scheduler, and a pure
answer‑checker. Pick up the next verse that's due, type it from memory and have
it graded, reveal, and rate yourself; the schedule updates and the verse comes
back later.
About 5–6 hours total, across two sittings. The first ~3 hours were the core build — the Leitner scheduler, the Supabase schema, and the /review screen — and most of that time went to fighting deployment and auth rather than the scheduling logic itself. The second ~2–3 hours added the typed‑answer checker (with the B_Check_Answers conformance tests), imported the challenge's B_Cards deck, added a simulated‑clock control so the demo deck can be advanced without waiting real days, and got the whole thing onto Vercel — where the first build broke on a missing env var.
Every table has a security rule that only lets you see rows tied to your own account (auth.uid()). That's the right way to build something multiple people will use, but it created a real problem. If someone visits /review without signing in, they don't get an error, they just see an empty screen with no verses to review. It looks broken even though nothing is technically wrong.
At first I added a simple magic link sign in (an email with a link you click to log in, no password needed) to solve this. But once I actually tested it with an outside reviewer, it broke in three different ways in a row: the login email pointed to my local computer instead of the live site, a new user account failed to create in the database, and then Supabase's email system hit a rate limit from too many attempts. Each of those was a real, separate bug, not the same one repeating.
After the third failure, I made the call to remove sign in from the review screen entirely and replace it with a shared "demo mode" that anyone can use immediately, no login at all. This meant writing new database policies that let anyone read and update one fixed demo account's data, instead of requiring their own account. It felt like a step backward from proper multi user design, but it was the right call given that the whole point was for someone else to actually be able to test it without me standing over their shoulder.
next.config.mjs has a setting called extensionAlias that tells the bundler to treat .js file imports as if they were .ts files. My scheduler code imports its own files using .js at the end, which is normal in plain JavaScript projects, but Next.js's default bundler doesn't automatically know those .js imports actually point to .ts files, so I forced it to understand that with this setting. The catch is this only works with one specific bundler. If someone runs the newer, faster dev server instead, it breaks with no warning at all, and nothing in the scheduler code explains why this setting exists. The clean fix would be to remove the .js extensions from the imports or turn the scheduler into its own separate package. I didn't have time to do either.
A second thing, smaller but still worth naming: because reviewing a card pushes it to a future due date, once someone finishes reviewing everything, the app just says nothing is due, with no way to try it again without resetting the data. I ended up adding a "Reset demo deck" button and a "+1 day / +7 days" simulated‑clock control directly in the app so anyone can keep demoing without database access, but both only exist because the natural behavior of a spaced repetition app makes repeat demoing awkward.
A third thing, and the one that actually bit me: the /review screen builds its Supabase client during render, so next build runs that code while prerendering the page and the whole build hard‑fails if NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY aren't set — which is exactly how the first Vercel deploy broke. It's fine in practice because the values are always there, but one missing env var takes down the entire build instead of just that one page. The page is fully client‑driven and shouldn't be prerendered at all; the clean fix is to opt it out of static generation.
I built this with Claude Code end to end — schema, the Leitner scheduler, the answer checker, the tests, the review screen — reviewing each piece as it came back and pushing back on scope (no verse picker, no translation licensing). The line I held firm on is that the scheduler and checker stay pure functions with no clock and no I/O, because that is what makes them testable, and I had it restructure code more than once when it drifted off that. Where I overrode it: it let the vocalised Arabic in the B_Cards deck drift under Unicode normalization, so I made it regenerate that file byte‑for‑byte from the test fixtures instead of hand‑retyping; and when /review came up empty I stopped it from rewriting the query and traced the real cause to row‑level‑security policies that had never been applied to the database, not to the code.
| Path | What it is |
|---|---|
supabase/migrations/ |
0001 schema/RLS · 0002 demo mode · 0003 Leitner box columns · 0004 seed the B_Cards deck for the demo user |
src/scheduler/leitner.ts |
Pure Leitner box scheduler (Build Lane Challenge B_Rules) — createCard, review, isDue, selectDueQueue |
src/scheduler/sm2.ts |
Earlier SM‑2 scheduler — kept for reference, no longer wired to the app |
src/checker/ |
Pure answer checker (B_Rules "Answer checking" / "Partial credit") — normalizeForCheck, checkAnswer |
src/i18n/direction.ts |
directionForLanguage / passageTextAttrs for RTL rendering |
app/, lib/supabase/ |
Next.js (App Router) app — the /review screen |
test/ |
Unit tests + deterministic 30‑day simulation |
scripts/b-cards.json |
The Build Lane Challenge B_Cards deck (16 passages, en/zh/ar/hi) |
scripts/import-passages.ts |
Load a verses JSON file into passages |
scripts/sim-report.ts |
Print the 30‑day simulation as tables: npx tsx scripts/sim-report.ts [seed] [days] |
npm install
npm run dev # Next.js dev server → http://localhost:3000 (redirects to /review)
npm run build # production build
npm test # vitest run — 81 tests (B_Check_Schedule + B_Check_Answers conformance)
npm run typecheck # tsc --noEmitapp/review/page.tsx is a client component. It:
- Reads
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEYfrom.env.local(lib/supabase/client.ts, via@supabase/ssrcreateBrowserClient). - Demo mode, no sign-in.
0002_demo_mode.sqlopens one fixed demo user's rows to the anon role, so/reviewreads and writes that shared deck with no auth. A "Reset demo deck" button re-arms it. - Loads the due queue:
cardsembedded withpassages, filteredis_suspended = falseanddue_at <= now, then ordered client-side byselectDueQueue()— most overdue → lowest box → reference A-Z, capped at 20 (B_Rulesdaily cap). - Per card: shows the reference. For a language the checker knows
(
en/ar/hi/zh) you can type the passage and Check it — that runs the purecheckAnswer()and reveals the verse with acorrect/partial/incorrectverdict; Reveal without checking skips straight to the text. Either way the passage renders with its owndir/lang(passageTextAttrs), then four grade buttons with next-interval previews. - On a grade, runs the pure
review()(the UI suppliesnow), thenUPDATE cards(box, due date) +INSERT reviews(box transition).
Simulated clock. Every clock read on the page — queue load, previews, the
grade write, the demo reset — goes through one now = Date.now() + offset. The
+1 day / +7 days / Today control moves offset in whole days (persisted to
localStorage), so the shared demo deck can be pushed past its due dates
without waiting real time. The scheduler itself still never reads a clock.
.env.local is git-ignored (.env.*). The anon key is meant to ship to the
browser; RLS is what protects data.
src/scheduler/leitner.ts implements the Leitner box system from the Build Lane
Challenge Option B B_Rules tab. Two hard rules:
- Pure. The input
Cardis never mutated;review()returns a new card plus a log row. - No ambient clock. Time enters only through the
nowargument (epoch ms). Nothing callsDate.now(),new Date(), orperformance.now(). Same arguments → same result, whenever it runs. A non-finitenowthrows.
import { createCard, review, isDue } from './src/scheduler/leitner';
let card = createCard(now); // box 0, due now — `now` is epoch ms
if (isDue(card, now)) {
const { card: next, log } = review(card, 'good', now); // 'again'|'hard'|'good'|'easy'
// persist `next` to cards, insert `log` into reviews
}| Box | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| Interval | same day | 1d | 3d | 7d | 21d | 60d (max) |
again→ box 0 ·good→ box +1 ·easy→ box +2 (both capped at 5)hard→ box unchanged; interval =floor(0.6 × box interval), min 1 day- Next due date = review instant + interval (a late review reschedules from when it happened, not from the old due date)
test/leitner.schedule.test.ts replays all 8 B_Check_Schedule traces
step-for-step. test/leitner.test.ts covers each grade, the hard floor at every
box, purity, and selectDueQueue ordering.
src/checker/ grades a typed recitation against the card, following the
B_Rules "Answer checking" and "Partial credit" sections. Like the scheduler
it is pure — no I/O, no clock.
normalize.tsapplies the same normalisation to the card and the input before comparing: NFC, curly quotes → straight, full-width punctuation → ASCII, Arabic harakat / alef-wasla / alef-maqsura, the Devanagari nukta — then strips all punctuation, folds case, collapses whitespace.check.tscompares word-by-word foren/ar/hi(the count and 1-based position of every mismatch, plus missing / extra runs) and character-by-character forzh. The verdict iscorrect,partial, orincorrect—incorrectonce the substitution rate over the compared region reachesINCORRECT_RATIO(0.2), or nothing matched at all.
test/checker.test.ts replays all 21 B_Check_Answers rows and asserts the
exact numeric prose the sheet spells out.
Both are on the English Matthew 28:19 card, and in both the verdict category still matches the sheet — only a number differs.
- Word count — we report 24, the sheet says 22. The "Empty string" row's expected prose is "0 of 22 words matched". The B_Cards text for that verse ("Go ye therefore, and teach all nations, baptizing them in the name of the Father, and of the Son, and of the Holy Ghost:") is 24 whitespace-separated tokens, and the checker tokenises on whitespace, so it reports 24.
- Mismatch position — we report 15, the sheet says 16. In the "Curly apostrophe and quotes" row the input reads "Father’s" where the card has "Father". Normalisation straightens the curly apostrophe and then strips it, so "Father’s" collapses to the single token "fathers"; position-by-position comparison puts the wrong word at 15. Position 16 would be correct only if the apostrophe had split it into two tokens.
Each passage stores its own text and language (BCP‑47, e.g. en, zh-Hans,
ar, hi). Direction is per passage, not per app:
-
DB:
passages.directionis a generated column —rtlfor Arabic, Hebrew, Persian, Urdu, etc., otherwiseltr. -
UI: set
dirandlangon the element that renders the verse so an Arabic passage is right‑to‑left even inside an English UI (and vice versa):import { passageTextAttrs } from './src/i18n/direction'; <p {...passageTextAttrs(passage.language)}>{passage.text}</p> // -> <p lang="ar" dir="rtl">…</p>
Build components with CSS logical properties (
margin-inline-start,text-align: start,padding-inline) so they mirror automatically. For strings that mix scripts inline, wrap the foreign run in<bdi>ordir="auto".
Keep the RTL list in src/i18n/direction.ts (RTL_LANGUAGE_SUBTAGS) in sync
with the CASE in the migration's direction column.
npm i @supabase/supabase-js
SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... IMPORT_USER_ID=<uuid> \
npx tsx scripts/import-passages.ts ./verses.jsonInserting a passages row auto‑creates its cards row (trigger), so the import
only touches passages. direction is computed by the DB.
The Build Lane Challenge deck is committed as scripts/b-cards.json (16
passages, en/zh-Hans/ar/hi; the text is byte-identical to the checker's
B_Check_Answers fixtures). Migration 0004_seed_b_cards.sql loads it into the
shared demo account, so the live /review deck comes up populated —
npx tsx scripts/import-passages.ts scripts/b-cards.json does the same against
any user.
test/scheduler.unit.test.ts— every state transition, ease floor, interval cap, "successful review always moves the due date out", clock‑independence, input not mutated.test/simulate.ts+test/scheduler.simulation.test.ts— six passages (English, Mandarin, Arabic ×2, Hindi) withdiligent/realistic/leechlearner profiles played through 30 daily sessions using a seeded PRNG and a fixed epoch anchor. Asserts full determinism (same seed → identical history), per‑transition invariants, and sensible month‑end outcomes per profile.