Spaced repetition for 990 SAT words, using SM-2 (the scheduling algorithm behind Anki), plus a practice test that reproduces the Digital SAT Bluebook interface.
Works with no account and no backend: progress lives in IndexedDB on the device. Signing in with Google is optional and merges local progress into the cloud rather than overwriting it.
npm install
npm run devOpen http://localhost:3000. Everything works immediately — study, test, stats, settings. Accounts are simply hidden until Supabase is configured.
At supabase.com, create a project and note the Project URL and anon key from Project Settings → API.
Open the Supabase SQL Editor, paste the entire contents of supabase/schema.sql, and run it. This creates all six tables, enables Row Level Security on every user-owned table, and installs a trigger that provisions a settings row for each new account.
In Supabase, go to Authentication → Providers → Google and enable it. You will need a Google OAuth client ID and secret from the Google Cloud Console:
- Authorized redirect URI:
https://<your-project-ref>.supabase.co/auth/v1/callback
Then in Supabase Authentication → URL Configuration, add your app's URL (e.g. http://localhost:3000 and your production domain) to Redirect URLs.
cp .env.example .env.localFill in:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
The service role key is used only by the seed script. It must never reach the browser — it is not prefixed with NEXT_PUBLIC_, so Next.js will not bundle it.
set -a && source .env.local && set +a
npm run seedThis upserts all 990 cards and prints a verification count.
npm run devPush to GitHub, import the repo in Vercel, and add the three environment variables in Vercel's project settings. Add your production URL to Supabase's Redirect URLs list. That's the whole deploy.
src/lib/srs.ts is a pure, dependency-free implementation of SM-2 with Anki's default preset:
| Setting | Value |
|---|---|
| Learning steps | 1m, 10m |
| Relearning steps | 10m |
| Graduating interval | 1 day |
| Easy interval | 4 days |
| Starting ease | 250% |
| Minimum ease | 130% |
| Easy bonus | 1.3× |
| Hard multiplier | 1.2× |
| Leech threshold | 8 lapses |
| Interval fuzz | ±5% |
Answering Again on a review card sends it to relearning, drops ease by 0.20, and increments lapses. Hard multiplies the interval by 1.2 and drops ease by 0.15. Good multiplies by the current ease. Easy multiplies by ease × 1.3 and raises ease by 0.15.
The answer buttons preview the real next interval, computed with fuzz disabled so the label is stable between render and click.
Queue priority: due learning/relearning cards, then due reviews, then new cards (capped by the daily limit), then a 20-minute learn-ahead window so a session never stalls with only near-due cards left.
Because the scheduler is pure, it is trivially testable — no clock, no storage, no React. Every function takes now as a parameter.
All state is written to IndexedDB first (src/lib/local.ts), so the app is fully usable offline and without an account. Every storage call is wrapped so that a failure degrades to in-memory session state rather than crashing.
When a user signs in, fullSync in src/lib/sync.ts pulls remote rows and merges them with local rows. The conflict rule is the more advanced state wins, not last-write-wins:
- Higher
reps - Tie → longer
intervalDays - Tie → later
lastReviewedAt - Tie → later
updatedAt
This runs on every sign-in, not just the first, so studying on a phone and a laptop converges instead of one device clobbering the other. Review logs carry client-generated UUIDs, making the upserts idempotent.
Individual reviews are pushed immediately in the background; if a push fails, the next full sync reconciles it.
- RLS is enabled on
user_settings,card_states,review_logs,daily_progress, andtest_sessions. Each policy sets bothUSINGandWITH CHECKagainstauth.uid(), so a client cannot read or write another user's rows. cardsis world-readable and has no insert/update/delete policy, so the deck cannot be modified from the browser.- The service role key is server-only, used exclusively by
scripts/seed.mjs. - Google OAuth only — no password storage.
src/middleware.tsrefreshes expiring sessions so tokens do not go stale mid-session.- Database constraints (
checkon ease ≥ 1.3, ratings 1–4, non-negative counters) reject malformed writes even if the client is compromised.
Two visual registers, on purpose:
The app (Today, Study, Stats, Settings) uses Space Grotesk for headings, Inter for body, and JetBrains Mono for every number, with tabular figures so counters do not jitter as they change. The palette is near-black ink on off-white with four semantic accents: moss for mature, steel for young, amber for learning, rust for lapses. Those four colors mean the same thing everywhere — in the queue ribbon under the header, the collection bar, the forecast chart, and the answer buttons.
The test client deliberately abandons that identity and reproduces Bluebook: white background, the College Board blue on the Back/Next pills, dashed header rule, black question-number tile, letter-circle answer choices, cross-out tool, mark-for-review flag, and the question navigator with its legend. The point is that exam day should feel like a repeat, not a first encounter.
src/
app/
page.tsx Today dashboard
study/page.tsx Flashcard review
test/page.tsx Bluebook practice test
stats/page.tsx Statistics and test history
settings/page.tsx Limits, account, scheduler reference
auth/callback/ OAuth code exchange
components/
AppShell.tsx Header, desktop nav, mobile tab bar, queue ribbon
AuthButton.tsx
test/ Bluebook header, footer, question panel, modals
stats/ Heatmap, bar chart, stat tile
lib/
srs.ts SM-2 scheduler (pure)
types.ts Shared types
local.ts IndexedDB layer
sync.ts Merge and two-way sync
store.tsx React context tying it together
stats.ts Streak, retention, forecast, histograms
testgen.ts Test assembly and distractor selection
supabase/ Browser and server clients
data/vocab.json 990 words
supabase/schema.sql Tables, RLS policies, triggers
scripts/seed.mjs Deck seeding (service role)
Study: Space reveal · 1 Again · 2 Hard · 3 Good · 4 Easy · Enter reveal then Good
New words are introduced in a shuffled order, not the alphabetical order they're stored in — otherwise a session would drift through abase, abate, abdicate, abduct… and cluster similar prefixes together, which is exactly the kind of interference spaced repetition is supposed to avoid. The shuffle happens once per device, the first time the app runs, and is then persisted so reloading doesn't reshuffle mid-collection. It's a presentation detail, not part of the schedule, so it isn't synced across devices — signing in on a second device gets its own independent shuffle. The practice test was already randomized regardless, since buildTest shuffles its question pool on every run.
Dark mode has three states — light, dark, system — set from the toggle in the header or the Appearance section in Settings. It's stored in Settings.theme, persisted locally, and synced to the account the same way every other setting is. A blocking script in layout.tsx reads a localStorage mirror before React hydrates, so there's no flash of the wrong theme on load.
Every color in the app resolves through a CSS variable (src/app/globals.css), with light and dark values swapped by a .dark class on <html>. Component class names never change between themes — only what the variables resolve to — so adding a new dark-aware component just means using the existing tokens (bg-panel, text-ink, border-line, etc.) instead of raw Tailwind colors.
The practice test is the one deliberate exception: .bluebook is hardcoded to white-on-black-text regardless of theme, because the whole point of that screen is to look exactly like the real Digital SAT client every time, which is always light.
- The deck is fixed; there is no custom card creation.
- Cards show a definition only — no example sentences or audio, since the source data has neither.
- Practice test results are recorded separately and never alter flashcard scheduling. The tables are independent, so wiring them together later is additive.