Skip to content
kayracnkyPublic

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Lexicon — SAT Vocabulary SRS

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.


Quick start (local, no backend)

npm install
npm run dev

Open http://localhost:3000. Everything works immediately — study, test, stats, settings. Accounts are simply hidden until Supabase is configured.


Full setup (with accounts and sync)

1. Create a Supabase project

At supabase.com, create a project and note the Project URL and anon key from Project Settings → API.

2. Create the schema

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.

3. Enable Google sign-in

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.

4. Set environment variables

cp .env.example .env.local

Fill 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.

5. Seed the deck

set -a && source .env.local && set +a
npm run seed

This upserts all 990 cards and prints a verification count.

6. Run

npm run dev

7. Deploy

Push 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.


How the scheduler works

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.


Local-first architecture

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:

  1. Higher reps
  2. Tie → longer intervalDays
  3. Tie → later lastReviewedAt
  4. 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.


Security

  • RLS is enabled on user_settings, card_states, review_logs, daily_progress, and test_sessions. Each policy sets both USING and WITH CHECK against auth.uid(), so a client cannot read or write another user's rows.
  • cards is 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.ts refreshes expiring sessions so tokens do not go stale mid-session.
  • Database constraints (check on ease ≥ 1.3, ratings 1–4, non-negative counters) reject malformed writes even if the client is compromised.

Design notes

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.


Project structure

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)

Keyboard shortcuts

Study: Space reveal · 1 Again · 2 Hard · 3 Good · 4 Easy · Enter reveal then Good


Card order and dark mode

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.


Known scope limits

  • 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.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages